mirror of
https://github.com/samvallad33/vestige.git
synced 2026-06-28 21:49:38 +02:00
v2.0.8 Pulse — Reasoning Theater + Pulse Toast + Terrarium + Intel Mac (#44)
* docs: anti-hedging banner + master state-and-plan + UI roadmap
Project-level CLAUDE.md line 1 is now the anti-hedging banner referencing
the global rule at /Users/entity002/CLAUDE.md. When Sam commits to an
execution path (content cadence, shipping velocity, outreach, launch),
forecasts compute as if the commitment is already met — no discounting by
execution probability.
VESTIGE_STATE_AND_PLAN.md (1135 lines, 14 parts): single-page reference
any AI agent can read to plan the next phase without re-reading the repo.
Covers current state of every crate, the 24 MCP tools, the 19 WebSocket
event types, the 8 IDE integrations, and v2.1 through v3.0 roadmap.
UI_ROADMAP_v2.1_v2.2.md: compiled UI-gap analysis from 4 parallel research
agents (April 19). 10 critical UI gaps + 2026 bleeding-edge patterns +
unclaimed territory (menu bar widget, voice-first, AR/VR, time-scrubber).
* feat(v2.2-pulse): InsightToast + multi-process STORAGE docs
Two independent ship items landing together on the v2.2 branch ahead of
the Tuesday launch — a new UI surface that makes Vestige's cognitive
events visible in real time, and honest documentation of the multi-process
safety story that underpins the Stigmergic Swarm narrative.
**InsightToast** (apps/dashboard/src/lib/components/InsightToast.svelte,
apps/dashboard/src/lib/stores/toast.ts):
The dashboard already had a working WebSocket event stream on
ws://localhost:3927/ws that broadcast every cognitive event (dream
completions, consolidation sweeps, memory promotions/demotions, active-
forgetting suppression and Rac1 cascades, bridge discoveries). None of
that was surfaced to a user looking at anything other than the raw feed
view. InsightToast subscribes to the existing eventFeed derived store,
filters the spammy lifecycle events (Heartbeat, SearchPerformed,
RetentionDecayed, ActivationSpread, ImportanceScored, MemoryCreated),
and translates the narrative events into ephemeral toasts with a
bioluminescent colored accent matching EVENT_TYPE_COLORS.
Design notes:
- Rate-limited ConnectionDiscovered at 1.5s intervals (dreams emit many).
- Max 4 visible toasts, auto-dismiss at 4.5-7s depending on event weight.
- Click or Enter/Space to dismiss early.
- Bottom-right on desktop, top-banner on mobile.
- Reduced-motion honored via prefers-reduced-motion.
- Zero new websocket subscriptions — everything piggybacks on the
existing derived store.
Also added a "Preview Pulse" button to Settings -> Cognitive Operations
that fires a synthetic sequence of four toasts (DreamCompleted,
ConnectionDiscovered, MemorySuppressed, ConsolidationCompleted) so
the animation is demoable without waiting for real cognitive activity.
**Multi-Process Safety section in docs/STORAGE.md**:
Grounds the Stigmergic Swarm story with concrete tables of what the
current WAL + 5s busy_timeout configuration actually supports vs what
remains experimental. Key honest points:
- Shared --data-dir + ONE vestige-mcp + N clients is the shipping
pattern for multi-agent coordination.
- Two vestige-mcp processes writing the same file is experimental —
documented with the lsof + pkill recovery path.
- Roadmap lists the three items that would promote it to "supported":
advisory file lock, retry-with-jitter on SQLITE_BUSY, and a
concurrent-writer load test.
Build + typecheck:
- npm run check: 0 errors, 0 warnings across 583 files
- npm run build: clean static build, adapter-static succeeds
* feat(v2.3-terrarium): Memory Birth Ritual + event pipeline fix
v2.3 "Terrarium" headline feature. When a MemoryCreated event arrives, a
glowing orb materialises in the cosmic center (camera-relative z=-40),
gestates for ~800ms growing from a tiny spark into a full orb, then arcs
along a dynamic quadratic Bezier curve to the live position of the real
node, and on arrival hands off to the existing RainbowBurst + Shockwave +
RippleWave cascade. The target position is re-resolved every frame so
the force simulation can move the destination during flight without the
orb losing its mark.
**New primitive — EffectManager.createBirthOrb()** (effects.ts):
Accepts a camera, a color, a live target-position getter, and an
arrival callback. Owns a sprite pair (outer halo + inner bright core),
both depthTest:false with renderOrder 999/1000 so the orb is always
visible through the starfield and the graph.
- Gestation phase: easeOutCubic growth + sinusoidal pulse, halo tints
from neutral to event color as the ritual charges.
- Flight phase: QuadraticBezierCurve3 with control point at midpoint
raised on Y by 30 + 15% of orb-to-target distance (shooting-star
arc). Sampled with easeInOutQuad. Orb shrinks ~35% approaching target.
- Arrival: fires onArrive callback once, then fades out over 8 frames
while expanding slightly (energy dispersal).
- Caller's onArrive triggers the burst cascade at arrivePos (NOT the
original spawnPos — the force sim may have moved the target during
the ritual, so we re-read nodeManager.positions on arrival).
- Dispose path integrated with existing EffectManager.dispose().
**Event pipeline fix — Graph3D.processEvents()**:
Previously tracked `processedEventCount` assuming APPEND order, but
websocket.ts PREPENDS new events (index 0) and caps the array at
MAX_EVENTS. Result: only the first MemoryCreated event after page
load fired correctly; subsequent ones reprocessed the oldest entry.
Fixed to walk from index 0 until hitting the last-processed event
by reference identity — correct regardless of array direction or
eviction pressure. Events are then processed oldest-first so causes
precede effects. Found while wiring the v2.3 demo button; would have
manifested as "first orb only" in production.
**Demo trigger** (Settings -> Birth Ritual Preview):
Button that calls websocket.injectEvent() with a synthetic
MemoryCreated event, cycling through node types (fact / concept /
pattern / decision / person / place) to showcase the type-color
mapping. Downstream consumers can't distinguish synthetic from real,
so this drives the full ritual end-to-end. Intended for demo clip
recording for the Wednesday launch.
**Test coverage:**
- events.test.ts now tests the v2.3 birth ritual path: spawns 2+
sprites in the scene immediately, and fires the full arrival
cascade after driving the effects.update() loop past the ritual
duration.
- three-mock.ts extended with Vector3.addVectors, Vector3.applyQuaternion,
Color.multiplyScalar, Quaternion, QuadraticBezierCurve3, Texture,
and Object3D.quaternion/renderOrder so production code runs unaltered
in tests.
Build + typecheck:
- npm run check: 0 errors, 0 warnings across 583 files
- npm test: 251/251 pass (net +0 from v2.2)
- npm run build: clean adapter-static output
The Sanhedrin Shatter (anti-birth ritual for hallucination veto) needs
server-side event plumbing and is deferred. Ship this as the Wednesday
visual mic-drop.
* fix(v2.3): 5 FATAL bugs + 4 god-tier upgrades from post-ship audit
Post-ship audit surfaced 6 FATALs and 4 upgrades. Shipping 5 of the 6 +
all 4 upgrades. FATAL 4 (VRAM hemorrhage from un-pooled label canvases
in createTextSprite) is pre-existing, not from this session, and scoped
separately for a proper texture-pool refactor.
**FATAL 1 — Toast Silent Lobotomy** (stores/toast.ts)
Subscriber tracked events[0] only. When Svelte batched multiple events
in one update tick (swarm firing DreamCompleted + ConnectionDiscovered
within the same millisecond), every event but the newest got silently
dropped. Fixed to walk from index 0 until hitting lastSeen — same
pattern as Graph3D.processEvents. Processes oldest-first to preserve
narrative order.
**FATAL 2 — Premature Birth** (graph/nodes.ts + graph/events.ts)
Orb flight is 138 frames; materialization was 30 frames. Node popped
fully grown ~100 frames before orb arrived — cheap UI glitch instead
of a biological birth. Added `addNode(..., { isBirthRitual: true })`
option that reserves the physics slot but hides mesh/glow/label and
skips the materializing queue. New `igniteNode(id)` flips visibility
and enqueues materialization. events.ts onArrive now calls igniteNode
at the exact docking moment, so the elastic spring-up peaks on impact.
**FATAL 3 — 120Hz ProMotion Time-Bomb** (components/Graph3D.svelte)
All physics + effect counters are frame-based. On a 120Hz display every
ritual ran at 2x speed. Added a `lastTime`-based governor in animate()
that early-returns if dt < 16ms, clamping effective rate to ~60fps.
`- (dt % 16)` carry avoids long-term drift. Zero API changes; tonight's
fast fix until physics is rewritten to use dt.
**FATAL 5 — Bezier GC Panic** (graph/effects.ts birth-orb update)
Flight phase allocated a new Vector3 (control point) and a new
QuadraticBezierCurve3 every frame per orb. With 3 orbs in flight that's
360 objects/sec for the GC to collect. Rewrote as inline algebraic
evaluation — zero allocations per frame, identical curve.
**FATAL 6 — Phantom Shockwave** (graph/events.ts)
A 166ms setTimeout fired the 2nd shockwave. If the user navigated
away during that window the scene was disposed, the timer still
fired, and .add() on a dead scene threw unhandled rejection. Dropped
the setTimeout entirely; both shockwaves fire immediately in onArrive
with different scales/colors for the same layered-crash feel.
**UPGRADE 1 — Sanhedrin Shatter** (graph/effects.ts birth-orb update)
If getTargetPos() returns undefined AFTER gestation (target node was
deleted mid-ritual — Stop hook sniping a hallucination), the orb
turns blood-red, triggers a violent implosion in place, and skips
the arrival cascade. Cognitive immune system made visible.
**UPGRADE 2 — Newton's Cradle** (graph/events.ts onArrive)
On docking the target mesh's scale gets bumped 1.8×, so the elastic
materialization + force-sim springs physically recoil instead of the
orb landing silently. The graph flinches when an idea is born into it.
**UPGRADE 3 — Hover Panic** (stores/toast.ts + InsightToast.svelte)
Paused dwell timer on mouseenter/focus, resume on mouseleave/blur.
Stored remaining ms at pause so resume schedules a correctly-sized
timer. CSS pairs via `animation-play-state: paused` on the progress
bar. A toast the user is reading no longer dismisses mid-sentence.
**UPGRADE 4 — Event Horizon Guard** (components/Graph3D.svelte)
If >MAX_EVENTS (200) events arrive in one tick, lastProcessedEvent
falls off the end of the array and the walk consumes ALL 200 entries
as "fresh" — GPU meltdown from 200 simultaneous births. Detect the
overflow and drop the batch with a console.warn, advancing the
high-water mark so next frame is normal.
Build + test:
- npm run check: 0 errors, 0 warnings
- npm test: 251/251 pass
- npm run build: clean static build
* test(v2.3): full e2e + integration coverage for Pulse + Birth Ritual
Post-ship verification pass — five parallel write-agents produced 229 new
tests across vitest units, vitest integration, and Playwright browser e2e.
Net suite: 361 vitest pass (up from 251, +110) and 9/9 Playwright pass on
back-to-back runs.
**toast.test.ts (NEW, 661 lines, 42 tests)**
Silent-lobotomy batch walk proven (multi-event tick processes ALL, not
just newest, oldest-first ordering preserved). Hover-panic pause/resume
with remaining-ms math. All 9 event type translations asserted, all 11
noise types asserted silent. ConnectionDiscovered 1500ms throttle.
MAX_VISIBLE=4 eviction. clear() tears down all timers. fireDemoSequence
staggers 4 toasts at 800ms intervals. vi.useFakeTimers + vi.mock of
eventFeed; vi.resetModules in beforeEach for module-singleton isolation.
**websocket.test.ts (NEW, 247 lines, 30 tests)**
injectEvent adds to front, respects MAX_EVENTS=200 with FIFO eviction,
triggers eventFeed emissions. All 6 derived stores (isConnected,
heartbeat, memoryCount, avgRetention, suppressedCount, uptimeSeconds)
verified — defaults, post-heartbeat values, clearEvents preserves
lastHeartbeat. 13 formatUptime boundary cases (0/59/60/3599/3600/
86399/86400 seconds + negative / NaN / ±Infinity).
**effects.test.ts (EXTENDED, +501 lines, +21 tests, 51 total)**
createBirthOrb full lifecycle — sprite count (halo + core), cosmic
center via camera.quaternion, gestation phase (position lock, opacity
rise, scale easing, color tint), flight Bezier arc above linear
midpoint at t=0.5, dynamic mid-flight target redirect. onArrive fires
exactly once at frame 139. Post-arrival fade + disposal cleans scene
children. Sanhedrin Shatter: target goes undefined mid-flight →
onArrive NEVER called, implosion spawned, halo blood-red, eventual
cleanup. dispose() cleans active orbs. Multiple simultaneous orbs.
Custom gestation/flight frame opts honored. Zero-alloc invariant
smoke test (6 orbs × 150 frames, no leaks).
**nodes.test.ts (EXTENDED, +197 lines, +10 tests, 42 total)**
addNode({isBirthRitual:true}) hides mesh/glow/label immediately,
stamps birthRitualPending sentinel with correct totalFrames +
targetScale, does NOT enqueue materialization. igniteNode flips
visibility + enqueues materialization. Idempotent — second call
no-op. Non-ritual nodes unaffected. Unknown id is safe no-op.
Position stored in positions map while invisible (force sim still
sees it). removeNode + late igniteNode is safe.
**events.test.ts (EXTENDED, +268 lines, +7 tests, 55 total)**
MemoryCreated → mesh hidden immediately, 2 birth-orb sprites added,
ZERO RingGeometry meshes and ZERO Points particles at spawn. Full
ritual drive → onArrive fires, node visible + materializing, sentinel
cleared. Newton's Cradle: target mesh scale exactly 0.001 * 1.8 right
after arrival. Dual shockwave: exactly 2 Ring meshes added. Re-read
live position on arrival — force-sim motion during ritual → burst
lands at the NEW position. Sanhedrin abort path → rainbow burst,
shockwave, ripple wave are NEVER called (vi.spyOn).
**three-mock.ts (EXTENDED)**
Added Color.setRGB — production Three.js has it, the Sanhedrin-
Shatter path in effects.ts uses it. Two write-agents independently
monkey-patched the mock inline; consolidated as a 5-line mock
addition so tests stay clean.
**e2e/pulse-toast.spec.ts (NEW, 235 lines, 6 Playwright tests)**
Navigate /dashboard/settings → click Preview Pulse → assert first
toast appears within 500ms → assert >= 2 toasts visible at peak.
Click-to-dismiss removes clicked toast (matched by aria-label).
Hover survives >8s past the 5.5s dwell. Keyboard Enter dismisses
focused toast. CSS animation-play-state:paused on .toast-progress-
fill while hovered, running on mouseleave. Screenshots attached to
HTML report. Zero backend dependency (fireDemoSequence is purely
client-side).
**e2e/birth-ritual.spec.ts (NEW, 199 lines, 3 Playwright tests)**
Canvas mounts on /dashboard/graph (gracefully test.fixme if MCP
backend absent). Settings button injection + SPA route to /graph
→ screenshot timeline at t=0/500/1200/2000/2400/3000ms attached
to HTML report. pageerror + console-error listeners catch any
crash (would re-surface FATAL 6 if reintroduced). Three back-to-
back births — no errors, canvas still dispatches clicks.
Run commands:
cd apps/dashboard && npm test # 361/361 pass, ~600ms
cd apps/dashboard && npx playwright test # 9/9 pass, ~25s
Typecheck: 0 errors, 0 warnings. Build: clean adapter-static.
* fix(graph): default /api/graph to newest-memory center, add sort param
memory_timeline PR #37 exposed the same class of bug in the graph
endpoint: the dashboard Graph page (and the /api/graph endpoint it
hits) defaulted to centering on the most-connected memory, ran BFS at
depth 3, and capped the subgraph at 150 nodes. On a mature corpus this
clustered the visualization around a historical hotspot and hid freshly
ingested memories that hadn't accumulated edges yet. User-visible
symptom: TimeSlider on /graph showing "Feb 21 → Mar 1 2026" when the
database actually contains memories through today (Apr 20).
**Backend (`crates/vestige-mcp/src/dashboard/handlers.rs`):**
- `GraphParams` gains `sort: Option<String>` (accepted: "recent" |
"connected", unknown falls back to "recent").
- New internal `GraphSort` enum + case-insensitive `parse()`.
- Extracted `default_center_id(storage, sort)` so handler logic and
tests share the same branching. Recent path picks `get_all_nodes(1,
0)` (ORDER BY created_at DESC). Connected path picks
`get_most_connected_memory`, degrading gracefully to recent if the
DB has no edges yet.
- Default behaviour flipped from "connected" to "recent" — matches
user expectation of "show me my recent stuff".
**Dashboard (`apps/dashboard`):**
- `api.graph()` accepts `sort?: 'recent' | 'connected'` with JSDoc
explaining the rationale.
- `/graph/+page.svelte` passes `sort: 'recent'` when no query or
center_id is active. Query / center_id paths unchanged — they
already carry their own centering intent.
**Tests:** 6 new unit tests in `handlers::tests`:
- `graph_sort_parse_defaults_to_recent` (None, empty, garbage,
"recent", "Recent", "RECENT")
- `graph_sort_parse_accepts_connected_case_insensitive`
- `default_center_id_recent_returns_newest_node` — ingest 3 nodes,
assert newest is picked
- `default_center_id_connected_prefers_hub_over_newest` — wire a hub
node with 3 spokes, then ingest a newer "lonely" node; assert the
hub wins in Connected mode even though it's older
- `default_center_id_connected_falls_back_to_recent_when_no_edges`
— fresh DB with no connections still returns newest, not 404
- `default_center_id_returns_not_found_on_empty_db` — both modes
return 404 cleanly on empty storage
Build + test:
- cargo test -p vestige-mcp --lib handlers:: → 6/6 pass
- cargo test --workspace --lib → 830/830 pass, 0 failed
- cargo clippy -p vestige-core -p vestige-mcp --lib -- -D warnings →
clean
- npm run check → 0 errors, 0 warnings
- npm test → 361/361 pass
Binary already installed at ~/.local/bin/vestige-mcp (copied from
cargo build --release -p vestige-mcp). New Claude Desktop / Code
sessions will pick it up automatically when they respawn their MCP
subprocess. The dashboard HTTP server on port 3927 needs a manual
relaunch from a terminal with the usual pattern:
nohup bash -c 'tail -f /dev/null | \
VESTIGE_DASHBOARD_ENABLED=true ~/.local/bin/vestige-mcp' \
> /tmp/vestige-mcp.log 2>&1 & disown
* feat(v2.4): UI expansion — 8 new surfaces exposing the cognitive engine
Sam asked: "Build EVERY SINGLE MISSING UI PIECE." 10 parallel agents shipped
10 new viewports over the existing Rust backend, then 11 audit agents
line-by-line reviewed each one, extracted pure-logic helpers, fixed ~30
bugs, and shipped 549 new unit tests. Everything wired through the layout
with single-key shortcuts and a live theme toggle.
**Eight new routes**
- `/reasoning` — Reasoning Theater: Cmd+K ask palette → animated 8-stage
deep_reference pipeline + FSRS-trust-scored evidence cards +
contradiction arcs rendered as live SVG between evidence nodes
- `/duplicates` — threshold-driven cluster detector with winner selection,
Merge/Review/Dismiss actions, debounced slider
- `/dreams` — Dream Cinema: trigger dream + scrubbable 5-stage replay
(Replay → Cross-reference → Strengthen → Prune → Transfer) + insight
cards with novelty glow
- `/schedule` — FSRS Review Calendar: 6×7 grid with urgency color
bands (overdue/today/week/future), retention sparkline, expand-day list
- `/importance` — 4-channel radar (Novelty/Arousal/Reward/Attention) with
composite score + top-important list
- `/activation` — live spreading-activation view: search → SVG concentric
rings with decay animation + live-mode event feed
- `/contradictions` — 2D cosmic constellation of conflicting memories,
arcs colored by severity, tooltips with previews
- `/patterns` — cross-project pattern transfer heatmap with category
filters, top-transferred sidebar
**Three layout additions**
- `AmbientAwarenessStrip` — slim top band with retention vitals, at-risk
count, active intentions, recent dream, activity sparkline, dreaming
indicator, Sanhedrin-watch flash. Pure `$derived` over existing stores.
- `ThemeToggle` — dark/light/auto cycle with matchMedia listener,
localStorage persistence, SSR-safe, reduced-motion-aware. Rendered in
sidebar footer next to the connection dot.
- `MemoryAuditTrail` — per-memory Sources panel integrated as a
Content/Audit tab into the existing /memories expansion.
**Pure-logic helper modules extracted (for testability + reuse)**
reasoning-helpers, duplicates-helpers, dream-helpers, schedule-helpers,
audit-trail-helpers, awareness-helpers, contradiction-helpers,
activation-helpers, patterns-helpers, importance-helpers.
**Bugs fixed during audit (not exhaustive)**
- Trust-color inconsistency between EvidenceCard and the page confidence
ring (0.75 boundary split emerald vs amber)
- `new Date('garbage').toLocaleDateString()` returned literal "Invalid Date"
in 3 components — all now return em-dash or raw string
- NaN propagation in `Math.max(0, Math.min(1, NaN))` across clamps
- Off-by-one PRNG in audit-trail seeded mock (seed === UINT32_MAX yielded
rand() === 1.0 → index out of bounds)
- Duplicates dismissals keyed by array index broke on re-fetch; now keyed
by sorted cluster member IDs with stale-dismissal pruning
- Empty-cluster crash in DuplicateCluster.pickWinner
- Undefined tags crash in DuplicateCluster.safeTags
- Debounce timer leak in threshold slider (missing onDestroy cleanup)
- Schedule day-vs-hour granularity mismatch between calendar cell and
sidebar list ("today" in one, "in 1d" in the other)
- Schedule 500-memory hard cap silently truncated; bumped to 2000 + banner
- Schedule DST boundary bug in daysBetween (wall-clock math vs
startOfDay-normalized)
- Dream stage clamp now handles NaN/Infinity/floats
- Dream double-click debounce via `if (dreaming) return`
- Theme setTheme runtime validation; initTheme idempotence (listener +
style-element dedup on repeat calls)
- ContradictionArcs node radius unclamped (trust < 0 or > 1 rendered
invalid sizes); tooltip position clamp (could push off-canvas)
- ContradictionArcs $state closure capture (width/height weren't reactive
in the derived layout block)
- Activation route was MISSING from the repo — audit agent created it
with identity-based event filtering and proper RAF cleanup
- Layout: ThemeToggle was imported but never rendered — now in sidebar
footer; sidebar overflow-y-auto added for the 16-entry nav
**Tests — 549 new, 910 total passing (0 failures)**
ReasoningChain 42 | EvidenceCard 50
DuplicateCluster 64 | DreamStageReplay 19
DreamInsightCard 43 | FSRSCalendar 32
MemoryAuditTrail 45 | AmbientAwareness 60
theme (store) 31 | ContradictionArcs 43
ActivationNetwork 54 | PatternTransfer 31
ImportanceRadar 35 | + existing 361 tests still green
**Gates passed**
- `npm run check`: 0 errors, 0 warnings across 623 files
- `npm test`: 910/910 passing, 22 test files
- `npm run build`: clean adapter-static output
**Layout wiring**
- Nav array expanded 8 → 16 entries (existing 8 + 8 new routes)
- Single-key shortcuts added: R/A/D/C/P/U/X/N (no conflicts with
existing G/M/T/F/E/I/S/,)
- Cmd+K palette search works across all 16
- Mobile nav = top 5 (Graph, Reasoning, Memories, Timeline, Feed)
- AmbientAwarenessStrip mounted as first child of <main>
- ThemeToggle rendered in sidebar footer (was imported-but-unmounted)
- Theme initTheme() + teardown wired into onMount cleanup chain
Net branch delta: 47 files changed, +13,756 insertions, -6 deletions
* chore(release): v2.0.8 "Pulse"
Bundled release: Reasoning Theater wired to the 8-stage deep_reference
cognitive pipeline, Pulse InsightToast, Memory Birth Ritual (v2.3
Terrarium), 7 new dashboard surfaces (/duplicates, /dreams, /schedule,
/importance, /activation, /contradictions, /patterns), 3D graph
brightness system with auto distance-compensation + user slider, and
contradiction-detection + primary-selection hardening in the
cross_reference tool. Intel Mac (x86_64-apple-darwin) also flows through
to the release matrix from PR #43.
Added:
- POST /api/deep_reference — HTTP surface for the 8-stage pipeline
- DeepReferenceCompleted WebSocket event (primary + supporting +
contradicting memory IDs for downstream graph animation)
- /reasoning route, full UI + Cmd+K Ask palette
- 7 new dashboard surfaces exposing the cognitive engine
- Graph brightness slider + localStorage persistence + distance-based
emissive compensation so nodes don't disappear into fog at zoom-out
Fixed:
- Contradiction-detection false positives: adjacent-domain memories no
longer flagged as conflicts (NEGATION_PAIRS wildcards removed,
shared-words floor 2 → 4, topic-sim floor 0.15 → 0.55, STAGE 5
overlap floor 0.15 → 0.4)
- Primary-memory selection: unified composite 0.5 × relevance + 0.2 ×
trust + 0.3 × term_presence with hard topic-term filter, closing the
class of bug where off-topic high-trust memories won queries about
specific subjects
- Graph default-load fallback from sort=recent to sort=connected when
the newest memory is isolated, both backend and client
Changed:
- Reasoning page information hierarchy: chain renders first as hero,
confidence meter + Primary Source citation footer below
- Cargo feature split: embeddings code-only + ort-download | ort-dynamic
backends; defaults preserve identical behavior for existing consumers
- CI release-build now gates PRs too so multi-platform regressions
surface pre-merge
This commit is contained in:
parent
5b993e841f
commit
6a807698ef
364 changed files with 19862 additions and 392 deletions
372
apps/dashboard/src/lib/components/ActivationNetwork.svelte
Normal file
372
apps/dashboard/src/lib/components/ActivationNetwork.svelte
Normal file
|
|
@ -0,0 +1,372 @@
|
|||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import {
|
||||
activationColor,
|
||||
applyDecay,
|
||||
edgeStagger,
|
||||
initialActivation,
|
||||
isVisible,
|
||||
layoutNeighbours,
|
||||
} from './activation-helpers';
|
||||
|
||||
/**
|
||||
* ActivationNetwork — visualizes spreading activation (Collins & Loftus 1975)
|
||||
* across the cognitive memory graph.
|
||||
*
|
||||
* Every burst places a source node at the center with activated neighbours
|
||||
* on concentric rings. Edges draw in with a staggered delay to visualize the
|
||||
* activation wavefront; ripple waves expand outward from the source on each
|
||||
* burst; activation level decays every animation frame by 0.93 until it
|
||||
* drops below 0.05, at which point the node fades out.
|
||||
*
|
||||
* The component supports multiple overlapping bursts — each call to
|
||||
* `trigger(sourceId, sourceLabel, neighbours)` merges into the current
|
||||
* activation state rather than replacing it, so live mode feels like a
|
||||
* continuous neural storm instead of a reset.
|
||||
*/
|
||||
|
||||
export interface ActivationNode {
|
||||
id: string;
|
||||
label: string;
|
||||
nodeType: string;
|
||||
// Neighbours only — omit on source
|
||||
score?: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
width?: number;
|
||||
height?: number;
|
||||
/** Current focused burst; null when idle */
|
||||
source?: ActivationNode | null;
|
||||
/** Neighbours of the current focused burst (drawn immediately on mount) */
|
||||
neighbours?: ActivationNode[];
|
||||
/** Bursts triggered via live mode — each one overlays on the graph */
|
||||
liveBurstKey?: number;
|
||||
liveBurst?: { source: ActivationNode; neighbours: ActivationNode[] } | null;
|
||||
}
|
||||
|
||||
let {
|
||||
width = 900,
|
||||
height = 560,
|
||||
source = null,
|
||||
neighbours = [],
|
||||
liveBurstKey = 0,
|
||||
liveBurst = null,
|
||||
}: Props = $props();
|
||||
|
||||
// Decay/geometry constants live in `./activation-helpers` so the pure-
|
||||
// function test suite can exercise them without rendering Svelte. These
|
||||
// two visual-only constants stay local because they're tied to the SVG
|
||||
// node drawing below.
|
||||
const SOURCE_RADIUS = 22;
|
||||
const NEIGHBOUR_RADIUS_BASE = 14;
|
||||
|
||||
interface ActiveNode {
|
||||
id: string;
|
||||
label: string;
|
||||
nodeType: string;
|
||||
x: number;
|
||||
y: number;
|
||||
activation: number; // 0..1 — drives size and opacity
|
||||
isSource: boolean;
|
||||
sourceBurstId: number; // which burst does this node belong to
|
||||
}
|
||||
|
||||
interface ActiveEdge {
|
||||
burstId: number;
|
||||
sourceNodeId: string;
|
||||
targetNodeId: string;
|
||||
// Each edge has its own draw progress so we can stagger them
|
||||
drawProgress: number; // 0..1
|
||||
staggerDelay: number; // frames to wait before drawing
|
||||
framesElapsed: number;
|
||||
}
|
||||
|
||||
interface Ripple {
|
||||
burstId: number;
|
||||
x: number;
|
||||
y: number;
|
||||
radius: number;
|
||||
opacity: number;
|
||||
}
|
||||
|
||||
let activeNodes = $state<ActiveNode[]>([]);
|
||||
let activeEdges = $state<ActiveEdge[]>([]);
|
||||
let ripples = $state<Ripple[]>([]);
|
||||
|
||||
let burstCounter = 0;
|
||||
let animationFrame: number | null = null;
|
||||
let lastPropSource: string | null = null;
|
||||
let lastLiveKey = 0;
|
||||
|
||||
function triggerBurst(
|
||||
src: ActivationNode,
|
||||
nbrs: ActivationNode[],
|
||||
centerX: number,
|
||||
centerY: number
|
||||
) {
|
||||
burstCounter += 1;
|
||||
const burstId = burstCounter;
|
||||
|
||||
// If a live burst hits the same source that is already at center, offset
|
||||
// it slightly so the visual distinction is preserved without chaos.
|
||||
const jitter = liveBurstKey > 0 && activeNodes.length > 0 ? 40 : 0;
|
||||
const cx = centerX + (Math.random() - 0.5) * jitter;
|
||||
const cy = centerY + (Math.random() - 0.5) * jitter;
|
||||
|
||||
// Ripple wave from this source
|
||||
ripples = [
|
||||
...ripples,
|
||||
{ burstId, x: cx, y: cy, radius: SOURCE_RADIUS, opacity: 0.75 },
|
||||
{ burstId, x: cx, y: cy, radius: SOURCE_RADIUS, opacity: 0.5 },
|
||||
];
|
||||
|
||||
// Source node — full activation
|
||||
const sourceNode: ActiveNode = {
|
||||
id: `${src.id}::${burstId}`,
|
||||
label: src.label,
|
||||
nodeType: 'source',
|
||||
x: cx,
|
||||
y: cy,
|
||||
activation: 1,
|
||||
isSource: true,
|
||||
sourceBurstId: burstId,
|
||||
};
|
||||
|
||||
const neighbourNodes: ActiveNode[] = [];
|
||||
const newEdges: ActiveEdge[] = [];
|
||||
|
||||
// Slight rotation per burst so overlapping bursts don't fully collide
|
||||
const angleOffset = (burstCounter * 0.37) % (Math.PI * 2);
|
||||
const allPositions = layoutNeighbours(cx, cy, nbrs.length, angleOffset);
|
||||
|
||||
nbrs.forEach((nbr, i) => {
|
||||
const pos = allPositions[i];
|
||||
if (!pos) return;
|
||||
|
||||
neighbourNodes.push({
|
||||
id: `${nbr.id}::${burstId}`,
|
||||
label: nbr.label,
|
||||
nodeType: nbr.nodeType,
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
activation: initialActivation(i, nbrs.length),
|
||||
isSource: false,
|
||||
sourceBurstId: burstId,
|
||||
});
|
||||
|
||||
newEdges.push({
|
||||
burstId,
|
||||
sourceNodeId: sourceNode.id,
|
||||
targetNodeId: `${nbr.id}::${burstId}`,
|
||||
drawProgress: 0,
|
||||
staggerDelay: edgeStagger(i),
|
||||
framesElapsed: 0,
|
||||
});
|
||||
});
|
||||
|
||||
activeNodes = [...activeNodes, sourceNode, ...neighbourNodes];
|
||||
activeEdges = [...activeEdges, ...newEdges];
|
||||
}
|
||||
|
||||
function tick() {
|
||||
// Decay node activations (Collins & Loftus 1975, 0.93/frame).
|
||||
let nextNodes: ActiveNode[] = [];
|
||||
for (const n of activeNodes) {
|
||||
const nextActivation = applyDecay(n.activation);
|
||||
if (!isVisible(nextActivation)) continue;
|
||||
nextNodes.push({ ...n, activation: nextActivation });
|
||||
}
|
||||
activeNodes = nextNodes;
|
||||
|
||||
// Advance edge draw progress (only for edges whose endpoints still exist)
|
||||
const liveIds = new Set(nextNodes.map((n) => n.id));
|
||||
let nextEdges: ActiveEdge[] = [];
|
||||
for (const e of activeEdges) {
|
||||
if (!liveIds.has(e.sourceNodeId) || !liveIds.has(e.targetNodeId)) continue;
|
||||
const elapsed = e.framesElapsed + 1;
|
||||
let progress = e.drawProgress;
|
||||
if (elapsed >= e.staggerDelay) {
|
||||
// 0..1 over ~15 frames (~0.25s at 60fps)
|
||||
progress = Math.min(1, progress + 1 / 15);
|
||||
}
|
||||
nextEdges.push({ ...e, framesElapsed: elapsed, drawProgress: progress });
|
||||
}
|
||||
activeEdges = nextEdges;
|
||||
|
||||
// Expand ripples outward, fade opacity
|
||||
let nextRipples: Ripple[] = [];
|
||||
for (const r of ripples) {
|
||||
const nextRadius = r.radius + 6;
|
||||
const nextOpacity = r.opacity * 0.96;
|
||||
if (nextOpacity < 0.02 || nextRadius > Math.max(width, height)) continue;
|
||||
nextRipples.push({ ...r, radius: nextRadius, opacity: nextOpacity });
|
||||
}
|
||||
ripples = nextRipples;
|
||||
|
||||
animationFrame = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
function clearBursts() {
|
||||
activeNodes = [];
|
||||
activeEdges = [];
|
||||
ripples = [];
|
||||
}
|
||||
|
||||
// Watch for prop-driven bursts (initial search result)
|
||||
$effect(() => {
|
||||
if (!source) return;
|
||||
const sourceKey = source.id;
|
||||
if (sourceKey === lastPropSource) return;
|
||||
lastPropSource = sourceKey;
|
||||
clearBursts();
|
||||
triggerBurst(source, neighbours, width / 2, height / 2);
|
||||
});
|
||||
|
||||
// Watch for live bursts — each keyed trigger overlays a new burst at a
|
||||
// random-ish location near center so they don't stack directly on top.
|
||||
$effect(() => {
|
||||
if (!liveBurst || liveBurstKey === 0) return;
|
||||
if (liveBurstKey === lastLiveKey) return;
|
||||
lastLiveKey = liveBurstKey;
|
||||
// Live bursts land near but not exactly on center so they're visually
|
||||
// distinct from the primary burst.
|
||||
const offsetX = (Math.random() - 0.5) * 120;
|
||||
const offsetY = (Math.random() - 0.5) * 120;
|
||||
triggerBurst(liveBurst.source, liveBurst.neighbours, width / 2 + offsetX, height / 2 + offsetY);
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
animationFrame = requestAnimationFrame(tick);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (animationFrame !== null) cancelAnimationFrame(animationFrame);
|
||||
});
|
||||
|
||||
function nodeColor(nodeType: string, isSource: boolean): string {
|
||||
return activationColor(nodeType, isSource);
|
||||
}
|
||||
|
||||
function edgePoint(edge: ActiveEdge): {
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
} | null {
|
||||
const src = activeNodes.find((n) => n.id === edge.sourceNodeId);
|
||||
const tgt = activeNodes.find((n) => n.id === edge.targetNodeId);
|
||||
if (!src || !tgt) return null;
|
||||
// Clip to current draw progress so the edge grows outward from source
|
||||
const x2 = src.x + (tgt.x - src.x) * edge.drawProgress;
|
||||
const y2 = src.y + (tgt.y - src.y) * edge.drawProgress;
|
||||
return { x1: src.x, y1: src.y, x2, y2 };
|
||||
}
|
||||
</script>
|
||||
|
||||
<svg
|
||||
{width}
|
||||
{height}
|
||||
viewBox="0 0 {width} {height}"
|
||||
class="w-full h-full block"
|
||||
aria-label="Spreading activation visualization"
|
||||
>
|
||||
<defs>
|
||||
<filter id="act-glow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="4" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
<filter id="act-glow-strong" x="-100%" y="-100%" width="300%" height="300%">
|
||||
<feGaussianBlur stdDeviation="8" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
<radialGradient id="ripple-grad" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="70%" stop-color="#818cf8" stop-opacity="0" />
|
||||
<stop offset="100%" stop-color="#818cf8" stop-opacity="0.7" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Ripple wavefronts (expanding circles from source) -->
|
||||
{#each ripples as r, i (i)}
|
||||
<circle
|
||||
cx={r.x}
|
||||
cy={r.y}
|
||||
r={r.radius}
|
||||
fill="none"
|
||||
stroke="#818cf8"
|
||||
stroke-width="1.5"
|
||||
opacity={r.opacity}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
<!-- Edges (drawn with stagger delay so activation appears to spread) -->
|
||||
{#each activeEdges as e, i (i)}
|
||||
{@const pt = edgePoint(e)}
|
||||
{#if pt}
|
||||
<line
|
||||
x1={pt.x1}
|
||||
y1={pt.y1}
|
||||
x2={pt.x2}
|
||||
y2={pt.y2}
|
||||
stroke="#818cf8"
|
||||
stroke-width="1.2"
|
||||
stroke-linecap="round"
|
||||
opacity={0.35 * e.drawProgress}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<!-- Nodes -->
|
||||
{#each activeNodes as n (n.id)}
|
||||
{@const color = nodeColor(n.nodeType, n.isSource)}
|
||||
{@const r = n.isSource
|
||||
? SOURCE_RADIUS * (0.7 + 0.3 * n.activation)
|
||||
: NEIGHBOUR_RADIUS_BASE * (0.5 + 0.8 * n.activation)}
|
||||
<g opacity={Math.min(1, n.activation * 1.25)}>
|
||||
<!-- Soft outer glow halo -->
|
||||
<circle
|
||||
cx={n.x}
|
||||
cy={n.y}
|
||||
r={r * 1.9}
|
||||
fill={color}
|
||||
opacity={0.18 * n.activation}
|
||||
filter="url(#act-glow-strong)"
|
||||
/>
|
||||
<!-- Core -->
|
||||
<circle
|
||||
cx={n.x}
|
||||
cy={n.y}
|
||||
r={r}
|
||||
fill={color}
|
||||
filter="url(#act-glow)"
|
||||
/>
|
||||
<!-- Inner highlight for depth -->
|
||||
<circle
|
||||
cx={n.x - r * 0.3}
|
||||
cy={n.y - r * 0.3}
|
||||
r={r * 0.35}
|
||||
fill="#ffffff"
|
||||
opacity={0.35 * n.activation}
|
||||
/>
|
||||
{#if n.isSource && n.label}
|
||||
<text
|
||||
x={n.x}
|
||||
y={n.y + r + 18}
|
||||
text-anchor="middle"
|
||||
fill="#e0e0ff"
|
||||
font-size="11"
|
||||
font-family="var(--font-mono)"
|
||||
opacity={0.9 * n.activation}
|
||||
>
|
||||
{n.label.length > 40 ? n.label.slice(0, 40) + '…' : n.label}
|
||||
</text>
|
||||
{/if}
|
||||
</g>
|
||||
{/each}
|
||||
</svg>
|
||||
312
apps/dashboard/src/lib/components/AmbientAwarenessStrip.svelte
Normal file
312
apps/dashboard/src/lib/components/AmbientAwarenessStrip.svelte
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
<!--
|
||||
AmbientAwarenessStrip — persistent slim top-of-viewport band surfacing
|
||||
live cognitive engine vitals without demanding attention.
|
||||
|
||||
Contents (left → right):
|
||||
1. Retention Vitals — pulsing dot + "N memories · X% avg retention"
|
||||
2. At-Risk Count — memories with retention < 0.3 (or "—" if unknown)
|
||||
3. Active Intentions — count of active intentions, pings pink if >5
|
||||
4. Recent Dream — last DreamCompleted within 24h summary
|
||||
5. Activity Pulse — 10-bar sparkline of events/min over last 5 min
|
||||
6. Now Dreaming? — violet pulsing dot while a Dream is in flight
|
||||
7. Sanhedrin Watch — subtle red flash on MemorySuppressed in last 10s
|
||||
|
||||
Design: full-width band, dark-glass backdrop, border-bottom synapse/15,
|
||||
height ≈36px, dim muted text with colored accents ONLY on pulsing/urgent
|
||||
items. Not clickable — ambient info only.
|
||||
|
||||
Mobile: collapses to items 1, 2, 6 to save width.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import {
|
||||
memoryCount,
|
||||
avgRetention,
|
||||
eventFeed,
|
||||
} from '$stores/websocket';
|
||||
import { api } from '$stores/api';
|
||||
import {
|
||||
bucketizeActivity,
|
||||
dreamInsightsCount,
|
||||
findRecentDream,
|
||||
formatAgo,
|
||||
hasRecentSuppression,
|
||||
isDreaming as isDreamingFn,
|
||||
parseEventTimestamp,
|
||||
} from './awareness-helpers';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// 1. Retention vitals — derived straight from heartbeat stores
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
const retentionPct = $derived(Math.round(($avgRetention ?? 0) * 100));
|
||||
const retentionHealthy = $derived(($avgRetention ?? 0) >= 0.5);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// 2. At-risk count — fetched once from /retention-distribution.
|
||||
// Sum buckets whose range label implies retention < 0.3 ("0-20%" and
|
||||
// "20-40%"). Robust to absent/unknown backend: stays `null` → shows "—".
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
let atRiskCount = $state<number | null>(null);
|
||||
|
||||
async function loadAtRisk(): Promise<void> {
|
||||
try {
|
||||
const dist = await api.retentionDistribution();
|
||||
// Prefer direct `endangered` list if backend populates it.
|
||||
if (Array.isArray(dist.endangered) && dist.endangered.length > 0) {
|
||||
atRiskCount = dist.endangered.length;
|
||||
return;
|
||||
}
|
||||
// Otherwise sum buckets whose lower bound < 30%.
|
||||
const buckets = dist.distribution ?? [];
|
||||
let total = 0;
|
||||
for (const b of buckets) {
|
||||
const m = /^(\d+)/.exec(b.range);
|
||||
if (!m) continue;
|
||||
const low = Number.parseInt(m[1], 10);
|
||||
if (Number.isFinite(low) && low < 30) total += b.count ?? 0;
|
||||
}
|
||||
atRiskCount = total;
|
||||
} catch {
|
||||
atRiskCount = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// 3. Active intentions — fetched once from /intentions?status=active
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
let intentionsCount = $state<number | null>(null);
|
||||
|
||||
async function loadIntentions(): Promise<void> {
|
||||
try {
|
||||
const res = await api.intentions('active');
|
||||
intentionsCount = res.total ?? res.intentions?.length ?? 0;
|
||||
} catch {
|
||||
intentionsCount = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// 4 & 6. Dream awareness — pure helpers scan $eventFeed. Newest-first.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
let nowTick = $state(Date.now());
|
||||
|
||||
const dreamState = $derived.by(() => {
|
||||
const feed = $eventFeed;
|
||||
const recent = findRecentDream(feed, nowTick);
|
||||
const recentAt = recent ? parseEventTimestamp(recent) ?? nowTick : null;
|
||||
const recentMsAgo = recentAt !== null ? nowTick - recentAt : null;
|
||||
return {
|
||||
isDreaming: isDreamingFn(feed, nowTick),
|
||||
recent,
|
||||
recentMsAgo,
|
||||
insights: dreamInsightsCount(recent),
|
||||
};
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// 5. Activity pulse — bucket $eventFeed timestamps into 10 × 30s buckets
|
||||
// over the last 5 minutes. Bucket 0 = oldest, 9 = newest.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
const sparkline = $derived(bucketizeActivity($eventFeed, nowTick));
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// 7. Sanhedrin watch — flash red on any MemorySuppressed in last 10s
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
const suppressionFlash = $derived(hasRecentSuppression($eventFeed, nowTick));
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Ticker — advance `nowTick` every second so time-based derived values
|
||||
// (dreaming window, activity window, suppression flash) refresh smoothly.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
onMount(() => {
|
||||
void loadAtRisk();
|
||||
void loadIntentions();
|
||||
const tickHandle = setInterval(() => {
|
||||
nowTick = Date.now();
|
||||
}, 1000);
|
||||
// Refresh the slow API-backed counts every 60s so they don't go stale.
|
||||
const slowHandle = setInterval(() => {
|
||||
void loadAtRisk();
|
||||
void loadIntentions();
|
||||
}, 60_000);
|
||||
return () => {
|
||||
clearInterval(tickHandle);
|
||||
clearInterval(slowHandle);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="ambient-strip relative flex h-9 w-full items-center gap-0 overflow-hidden border-b border-synapse/15 bg-black/40 px-3 text-[11px] text-dim backdrop-blur-md"
|
||||
class:ambient-flash={suppressionFlash}
|
||||
aria-label="Ambient cognitive vitals"
|
||||
>
|
||||
<!-- 1. Retention vitals — always visible -->
|
||||
<div class="strip-item" title="Total memories and average retention strength">
|
||||
<span class="relative inline-flex h-2 w-2 items-center justify-center">
|
||||
<span
|
||||
class="absolute inline-flex h-full w-full animate-ping rounded-full opacity-75"
|
||||
class:bg-recall={retentionHealthy}
|
||||
class:bg-warning={!retentionHealthy}
|
||||
></span>
|
||||
<span
|
||||
class="relative inline-flex h-2 w-2 rounded-full"
|
||||
class:bg-recall={retentionHealthy}
|
||||
class:bg-warning={!retentionHealthy}
|
||||
></span>
|
||||
</span>
|
||||
<span class="text-text/80 tabular-nums">{$memoryCount}</span>
|
||||
<span class="text-muted">memories</span>
|
||||
<span class="text-muted/60">·</span>
|
||||
<span class:text-recall={retentionHealthy} class:text-warning={!retentionHealthy}>
|
||||
{retentionPct}%
|
||||
</span>
|
||||
<span class="text-muted">avg retention</span>
|
||||
</div>
|
||||
|
||||
<div class="strip-divider" aria-hidden="true"></div>
|
||||
|
||||
<!-- 2. At-risk — always visible -->
|
||||
<div class="strip-item" title="Memories with retention below 30%">
|
||||
{#if atRiskCount !== null && atRiskCount > 0}
|
||||
<span class="font-semibold tabular-nums text-decay">{atRiskCount}</span>
|
||||
<span class="text-muted">at risk</span>
|
||||
{:else if atRiskCount === 0}
|
||||
<span class="text-muted tabular-nums">0</span>
|
||||
<span class="text-muted">at risk</span>
|
||||
{:else}
|
||||
<span class="text-muted/60">—</span>
|
||||
<span class="text-muted">at risk</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- 3. Active intentions — hidden on mobile -->
|
||||
<div class="strip-divider hidden md:block" aria-hidden="true"></div>
|
||||
<div class="strip-item hidden md:inline-flex" title="Active intentions (prospective memory)">
|
||||
{#if intentionsCount !== null}
|
||||
<span
|
||||
class="inline-flex h-2 w-2 rounded-full"
|
||||
class:bg-node-pattern={intentionsCount > 5}
|
||||
class:animate-ping-slow={intentionsCount > 5}
|
||||
class:bg-muted={intentionsCount <= 5}
|
||||
></span>
|
||||
<span
|
||||
class="tabular-nums"
|
||||
class:text-node-pattern={intentionsCount > 5}
|
||||
class:text-text={intentionsCount > 0 && intentionsCount <= 5}
|
||||
class:text-muted={intentionsCount === 0}
|
||||
>
|
||||
{intentionsCount}
|
||||
</span>
|
||||
<span class="text-muted">intentions</span>
|
||||
{:else}
|
||||
<span class="text-muted/60">— intentions</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- 4. Recent dream — hidden on mobile -->
|
||||
<div class="strip-divider hidden md:block" aria-hidden="true"></div>
|
||||
<div class="strip-item hidden md:inline-flex" title="Most recent Dream cycle completion">
|
||||
{#if dreamState.recent && dreamState.recentMsAgo !== null}
|
||||
<span class="text-dream/80">✦</span>
|
||||
<span class="text-muted">Last dream:</span>
|
||||
<span class="text-text/80">{formatAgo(dreamState.recentMsAgo)}</span>
|
||||
{#if dreamState.insights !== null}
|
||||
<span class="text-muted/60">·</span>
|
||||
<span class="text-text/80 tabular-nums">{dreamState.insights}</span>
|
||||
<span class="text-muted">insights</span>
|
||||
{/if}
|
||||
{:else}
|
||||
<span class="text-muted">No recent dream</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- 5. Activity pulse sparkline — hidden on mobile -->
|
||||
<div class="strip-divider hidden md:block" aria-hidden="true"></div>
|
||||
<div
|
||||
class="strip-item hidden md:inline-flex"
|
||||
title="Event throughput over the last 5 minutes (events per 30s)"
|
||||
>
|
||||
<span class="text-muted">activity</span>
|
||||
<div class="flex h-4 items-end gap-[2px]" aria-hidden="true">
|
||||
{#each sparkline as bar}
|
||||
<div
|
||||
class="w-[3px] rounded-sm bg-synapse/70"
|
||||
style="height: {Math.max(10, bar.ratio * 100)}%; opacity: {bar.count === 0 ? 0.18 : 0.5 + bar.ratio * 0.5};"
|
||||
></div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 6. Now dreaming? — always visible when active -->
|
||||
{#if dreamState.isDreaming}
|
||||
<div class="strip-divider" aria-hidden="true"></div>
|
||||
<div class="strip-item" title="A Dream cycle is currently in progress">
|
||||
<span class="relative inline-flex h-2 w-2 items-center justify-center">
|
||||
<span
|
||||
class="absolute inline-flex h-full w-full animate-ping rounded-full bg-dream opacity-75"
|
||||
></span>
|
||||
<span class="relative inline-flex h-2 w-2 rounded-full bg-dream"></span>
|
||||
</span>
|
||||
<span class="font-semibold tracking-wider text-dream-glow">DREAMING...</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Spacer -->
|
||||
<div class="flex-1"></div>
|
||||
|
||||
<!-- 7. Sanhedrin watch — subtle right-aligned flash, hidden on mobile -->
|
||||
{#if suppressionFlash}
|
||||
<div class="strip-item hidden md:inline-flex" title="A memory was just suppressed (Sanhedrin veto)">
|
||||
<span
|
||||
class="inline-flex h-2 w-2 animate-pulse rounded-full bg-decay shadow-[0_0_10px_rgba(239,68,68,0.7)]"
|
||||
></span>
|
||||
<span class="font-medium text-decay">Veto triggered</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.strip-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0 0.75rem;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.strip-divider {
|
||||
width: 1px;
|
||||
height: 14px;
|
||||
background: rgba(99, 102, 241, 0.12);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Subtle red wash when a suppression just fired. */
|
||||
.ambient-strip.ambient-flash {
|
||||
background:
|
||||
linear-gradient(90deg, rgba(239, 68, 68, 0.08), rgba(239, 68, 68, 0) 70%),
|
||||
rgba(0, 0, 0, 0.4);
|
||||
border-bottom-color: rgba(239, 68, 68, 0.35);
|
||||
transition: background 0.3s ease, border-color 0.3s ease;
|
||||
}
|
||||
|
||||
/* Slower "ping" for the intentions pink dot — less aggressive than the
|
||||
default Tailwind animate-ping. */
|
||||
@keyframes ping-slow {
|
||||
0% { transform: scale(1); opacity: 0.8; }
|
||||
80%, 100% { transform: scale(2); opacity: 0; }
|
||||
}
|
||||
:global(.animate-ping-slow) {
|
||||
animation: ping-slow 2.2s cubic-bezier(0, 0, 0.2, 1) infinite;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ambient-strip :global(.animate-ping),
|
||||
.ambient-strip :global(.animate-ping-slow),
|
||||
.ambient-strip :global(.animate-pulse) {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
421
apps/dashboard/src/lib/components/ContradictionArcs.svelte
Normal file
421
apps/dashboard/src/lib/components/ContradictionArcs.svelte
Normal file
|
|
@ -0,0 +1,421 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* ContradictionArcs — 2D cosmic constellation of conflicting memories.
|
||||
*
|
||||
* Renders each contradiction pair as two nodes connected by an arc.
|
||||
* Arc color = similarity severity (red/amber/yellow).
|
||||
* Arc thickness = min(trust_a, trust_b) — stake.
|
||||
* Node size = trust score. Node hue = node_type (bioluminescent).
|
||||
*
|
||||
* SVG-only (no Three.js) to keep it lightweight and print-friendly.
|
||||
*/
|
||||
|
||||
import {
|
||||
nodeColor,
|
||||
severityColor,
|
||||
severityLabel,
|
||||
nodeRadius,
|
||||
pairOpacity,
|
||||
truncate,
|
||||
} from './contradiction-helpers';
|
||||
|
||||
export interface Contradiction {
|
||||
memory_a_id: string;
|
||||
memory_b_id: string;
|
||||
memory_a_preview: string;
|
||||
memory_b_preview: string;
|
||||
memory_a_type?: string;
|
||||
memory_b_type?: string;
|
||||
memory_a_created?: string;
|
||||
memory_b_created?: string;
|
||||
memory_a_tags?: string[];
|
||||
memory_b_tags?: string[];
|
||||
trust_a: number; // 0..1
|
||||
trust_b: number; // 0..1
|
||||
similarity: number; // 0..1
|
||||
date_diff_days: number;
|
||||
topic: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
contradictions: Contradiction[];
|
||||
focusedPairIndex?: number | null;
|
||||
onSelectPair?: (index: number | null) => void;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
let {
|
||||
contradictions,
|
||||
focusedPairIndex = null,
|
||||
onSelectPair,
|
||||
width = 800,
|
||||
height = 600
|
||||
}: Props = $props();
|
||||
|
||||
// --- Polar layout: place pairs around a circle, with the arc crossing the interior. ---
|
||||
// Each pair is given a slot on the circumference; node A and node B are placed
|
||||
// symmetrically around that slot at a small angular offset proportional to similarity
|
||||
// (more similar = farther apart visually, so the tension is readable).
|
||||
// Wrapped in $derived so Svelte 5 re-computes when $state `width`/`height` change,
|
||||
// instead of capturing their initial values once.
|
||||
const geom = $derived.by(() => {
|
||||
const cx = width / 2;
|
||||
const cy = height / 2;
|
||||
const R = Math.min(width, height) * 0.38;
|
||||
return { cx, cy, R };
|
||||
});
|
||||
|
||||
interface NodePoint {
|
||||
x: number;
|
||||
y: number;
|
||||
trust: number;
|
||||
preview: string;
|
||||
type?: string;
|
||||
created?: string;
|
||||
tags?: string[];
|
||||
memoryId: string;
|
||||
pairIndex: number;
|
||||
side: 'a' | 'b';
|
||||
}
|
||||
|
||||
interface ArcShape {
|
||||
pairIndex: number;
|
||||
path: string;
|
||||
color: string;
|
||||
thickness: number;
|
||||
severity: string;
|
||||
topic: string;
|
||||
similarity: number;
|
||||
dateDiff: number;
|
||||
aPoint: NodePoint;
|
||||
bPoint: NodePoint;
|
||||
// Midpoint used for particle animation origin
|
||||
midX: number;
|
||||
midY: number;
|
||||
}
|
||||
|
||||
const layout = $derived.by((): { nodes: NodePoint[]; arcs: ArcShape[] } => {
|
||||
const nodes: NodePoint[] = [];
|
||||
const arcs: ArcShape[] = [];
|
||||
const n = contradictions.length || 1;
|
||||
|
||||
contradictions.forEach((c, i) => {
|
||||
const slot = (i / n) * Math.PI * 2 - Math.PI / 2;
|
||||
// Angular offset between A and B within the slot — proportional to sim
|
||||
const spread = 0.18 + c.similarity * 0.22;
|
||||
const angA = slot - spread;
|
||||
const angB = slot + spread;
|
||||
|
||||
// Slight radial jitter so the constellation doesn't look like a perfect ring
|
||||
const rA = geom.R + (Math.sin(i * 2.3) * 18);
|
||||
const rB = geom.R + (Math.cos(i * 1.7) * 18);
|
||||
|
||||
const aPoint: NodePoint = {
|
||||
x: geom.cx + Math.cos(angA) * rA,
|
||||
y: geom.cy + Math.sin(angA) * rA,
|
||||
trust: c.trust_a,
|
||||
preview: c.memory_a_preview,
|
||||
type: c.memory_a_type,
|
||||
created: c.memory_a_created,
|
||||
tags: c.memory_a_tags,
|
||||
memoryId: c.memory_a_id,
|
||||
pairIndex: i,
|
||||
side: 'a'
|
||||
};
|
||||
const bPoint: NodePoint = {
|
||||
x: geom.cx + Math.cos(angB) * rB,
|
||||
y: geom.cy + Math.sin(angB) * rB,
|
||||
trust: c.trust_b,
|
||||
preview: c.memory_b_preview,
|
||||
type: c.memory_b_type,
|
||||
created: c.memory_b_created,
|
||||
tags: c.memory_b_tags,
|
||||
memoryId: c.memory_b_id,
|
||||
pairIndex: i,
|
||||
side: 'b'
|
||||
};
|
||||
|
||||
nodes.push(aPoint, bPoint);
|
||||
|
||||
// Arc bends toward the centre — cosmic bridge.
|
||||
// Control point = midpoint pulled toward centre by (1 - similarity * 0.3)
|
||||
const mx = (aPoint.x + bPoint.x) / 2;
|
||||
const my = (aPoint.y + bPoint.y) / 2;
|
||||
const pullStrength = 0.55 - c.similarity * 0.25;
|
||||
const ctrlX = mx + (geom.cx - mx) * pullStrength;
|
||||
const ctrlY = my + (geom.cy - my) * pullStrength;
|
||||
|
||||
const thickness = 1 + Math.min(c.trust_a, c.trust_b) * 4;
|
||||
arcs.push({
|
||||
pairIndex: i,
|
||||
path: `M ${aPoint.x.toFixed(1)} ${aPoint.y.toFixed(1)} Q ${ctrlX.toFixed(1)} ${ctrlY.toFixed(1)} ${bPoint.x.toFixed(1)} ${bPoint.y.toFixed(1)}`,
|
||||
color: severityColor(c.similarity),
|
||||
thickness,
|
||||
severity: severityLabel(c.similarity),
|
||||
topic: c.topic,
|
||||
similarity: c.similarity,
|
||||
dateDiff: c.date_diff_days,
|
||||
aPoint,
|
||||
bPoint,
|
||||
midX: ctrlX,
|
||||
midY: ctrlY
|
||||
});
|
||||
});
|
||||
|
||||
return { nodes, arcs };
|
||||
});
|
||||
|
||||
// --- Hover tooltip state ---
|
||||
let hoverNode = $state<NodePoint | null>(null);
|
||||
let hoverArc = $state<ArcShape | null>(null);
|
||||
let mouseX = $state(0);
|
||||
let mouseY = $state(0);
|
||||
|
||||
function onMove(e: MouseEvent) {
|
||||
const rect = (e.currentTarget as SVGSVGElement).getBoundingClientRect();
|
||||
mouseX = e.clientX - rect.left;
|
||||
mouseY = e.clientY - rect.top;
|
||||
}
|
||||
|
||||
function handleArcClick(i: number) {
|
||||
if (!onSelectPair) return;
|
||||
onSelectPair(focusedPairIndex === i ? null : i);
|
||||
}
|
||||
|
||||
function handleBgClick() {
|
||||
onSelectPair?.(null);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative w-full" style="aspect-ratio: {width} / {height};">
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<svg
|
||||
{width}
|
||||
{height}
|
||||
viewBox="0 0 {width} {height}"
|
||||
class="w-full h-full"
|
||||
role="img"
|
||||
aria-label="Contradiction constellation map — click an arc to focus, click background to deselect"
|
||||
onmousemove={onMove}
|
||||
onmouseleave={() => { hoverNode = null; hoverArc = null; }}
|
||||
onclick={handleBgClick}
|
||||
>
|
||||
<defs>
|
||||
<!-- Radial glass background -->
|
||||
<radialGradient id="bgGrad" cx="50%" cy="50%" r="65%">
|
||||
<stop offset="0%" stop-color="#10102a" stop-opacity="0.9" />
|
||||
<stop offset="60%" stop-color="#0a0a1a" stop-opacity="0.7" />
|
||||
<stop offset="100%" stop-color="#050510" stop-opacity="0.4" />
|
||||
</radialGradient>
|
||||
|
||||
<!-- Soft arc gradients per severity, for a glow feel -->
|
||||
<linearGradient id="arcGradRed" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#ef4444" stop-opacity="0.1" />
|
||||
<stop offset="50%" stop-color="#ef4444" stop-opacity="1" />
|
||||
<stop offset="100%" stop-color="#ef4444" stop-opacity="0.1" />
|
||||
</linearGradient>
|
||||
<linearGradient id="arcGradAmber" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#f59e0b" stop-opacity="0.1" />
|
||||
<stop offset="50%" stop-color="#f59e0b" stop-opacity="1" />
|
||||
<stop offset="100%" stop-color="#f59e0b" stop-opacity="0.1" />
|
||||
</linearGradient>
|
||||
<linearGradient id="arcGradYellow" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#fde047" stop-opacity="0.1" />
|
||||
<stop offset="50%" stop-color="#fde047" stop-opacity="1" />
|
||||
<stop offset="100%" stop-color="#fde047" stop-opacity="0.1" />
|
||||
</linearGradient>
|
||||
|
||||
<!-- Node glow filter -->
|
||||
<filter id="nodeGlow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="2.5" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
|
||||
<filter id="arcGlow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="3" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Cosmic background -->
|
||||
<rect x="0" y="0" {width} {height} fill="url(#bgGrad)" rx="16" />
|
||||
|
||||
<!-- Subtle reference ring so the layout reads as a constellation -->
|
||||
<circle
|
||||
cx={geom.cx}
|
||||
cy={geom.cy}
|
||||
r={geom.R}
|
||||
fill="none"
|
||||
stroke="#6366f1"
|
||||
stroke-opacity="0.06"
|
||||
stroke-dasharray="2 6"
|
||||
/>
|
||||
<circle cx={geom.cx} cy={geom.cy} r="3" fill="#6366f1" opacity="0.4" />
|
||||
|
||||
<!-- Arcs (cosmic bridges) — rendered before nodes so nodes sit on top -->
|
||||
{#each layout.arcs as arc (arc.pairIndex)}
|
||||
{@const op = pairOpacity(arc.pairIndex, focusedPairIndex)}
|
||||
{@const isFocused = focusedPairIndex === arc.pairIndex}
|
||||
<!-- Outer halo -->
|
||||
<path
|
||||
d={arc.path}
|
||||
fill="none"
|
||||
stroke={arc.color}
|
||||
stroke-width={arc.thickness * 3}
|
||||
stroke-opacity={0.08 * op}
|
||||
stroke-linecap="round"
|
||||
filter="url(#arcGlow)"
|
||||
pointer-events="none"
|
||||
/>
|
||||
<!-- Primary arc -->
|
||||
<path
|
||||
d={arc.path}
|
||||
fill="none"
|
||||
stroke={arc.color}
|
||||
stroke-width={arc.thickness * (isFocused ? 1.6 : 1)}
|
||||
stroke-opacity={(isFocused ? 1 : 0.72) * op}
|
||||
stroke-linecap="round"
|
||||
class="cursor-pointer transition-all duration-200"
|
||||
onclick={(e) => { e.stopPropagation(); handleArcClick(arc.pairIndex); }}
|
||||
onmouseenter={() => (hoverArc = arc)}
|
||||
onmouseleave={() => (hoverArc = null)}
|
||||
aria-label="contradiction {arc.pairIndex + 1}: {arc.topic}"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onkeydown={(e) => { if (e.key === 'Enter') handleArcClick(arc.pairIndex); }}
|
||||
/>
|
||||
<!-- Particle: a small dashed overlay that drifts along the arc to show tension flow -->
|
||||
<path
|
||||
d={arc.path}
|
||||
fill="none"
|
||||
stroke={arc.color}
|
||||
stroke-width={Math.max(1, arc.thickness * 0.6)}
|
||||
stroke-opacity={0.85 * op}
|
||||
stroke-linecap="round"
|
||||
stroke-dasharray="2 14"
|
||||
class="arc-particle"
|
||||
style="animation-duration: {4 + (arc.pairIndex % 5)}s"
|
||||
pointer-events="none"
|
||||
/>
|
||||
{/each}
|
||||
|
||||
<!-- Nodes -->
|
||||
{#each layout.nodes as node, i (node.memoryId + '-' + node.side + '-' + i)}
|
||||
{@const op = pairOpacity(node.pairIndex, focusedPairIndex)}
|
||||
{@const isFocused = focusedPairIndex === node.pairIndex}
|
||||
{@const r = nodeRadius(node.trust)}
|
||||
{@const fill = nodeColor(node.type)}
|
||||
<!-- Outer glow -->
|
||||
<circle
|
||||
cx={node.x}
|
||||
cy={node.y}
|
||||
r={r * 2.2}
|
||||
fill={fill}
|
||||
opacity={0.12 * op}
|
||||
filter="url(#nodeGlow)"
|
||||
pointer-events="none"
|
||||
/>
|
||||
<!-- Core -->
|
||||
<circle
|
||||
cx={node.x}
|
||||
cy={node.y}
|
||||
r={r}
|
||||
fill={fill}
|
||||
opacity={op}
|
||||
stroke="#ffffff"
|
||||
stroke-opacity={isFocused ? 0.85 : 0.25}
|
||||
stroke-width={isFocused ? 2 : 1}
|
||||
class="cursor-pointer transition-all duration-200"
|
||||
onmouseenter={() => (hoverNode = node)}
|
||||
onmouseleave={() => (hoverNode = null)}
|
||||
onclick={(e) => { e.stopPropagation(); handleArcClick(node.pairIndex); }}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="memory {truncate(node.preview, 40)}"
|
||||
onkeydown={(e) => { if (e.key === 'Enter') handleArcClick(node.pairIndex); }}
|
||||
/>
|
||||
<!-- Label (truncated) — only shown for focused pair to avoid clutter -->
|
||||
{#if isFocused}
|
||||
<text
|
||||
x={node.x}
|
||||
y={node.y - r - 8}
|
||||
fill="#e0e0ff"
|
||||
font-size="10"
|
||||
font-family="var(--font-mono, monospace)"
|
||||
text-anchor="middle"
|
||||
pointer-events="none"
|
||||
>{truncate(node.preview, 40)}</text>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<!-- Legend top-left -->
|
||||
<g transform="translate(16, 16)" pointer-events="none">
|
||||
<rect x="0" y="0" width="170" height="66" rx="8"
|
||||
fill="#0a0a1a" fill-opacity="0.6" stroke="#6366f1" stroke-opacity="0.12" />
|
||||
<text x="10" y="16" fill="#7a7aaa" font-size="10" font-family="var(--font-mono, monospace)">SEVERITY</text>
|
||||
<circle cx="16" cy="30" r="4" fill="#ef4444" />
|
||||
<text x="26" y="33" fill="#e0e0ff" font-size="10" font-family="var(--font-mono, monospace)">strong (>0.7)</text>
|
||||
<circle cx="16" cy="44" r="4" fill="#f59e0b" />
|
||||
<text x="26" y="47" fill="#e0e0ff" font-size="10" font-family="var(--font-mono, monospace)">moderate (0.5-0.7)</text>
|
||||
<circle cx="16" cy="58" r="4" fill="#fde047" />
|
||||
<text x="26" y="61" fill="#e0e0ff" font-size="10" font-family="var(--font-mono, monospace)">mild (0.3-0.5)</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<!-- Hover tooltip (absolute, HTML for readability) -->
|
||||
{#if hoverNode}
|
||||
<div
|
||||
class="pointer-events-none absolute z-10 glass-panel rounded-lg px-3 py-2 text-xs max-w-xs shadow-xl"
|
||||
style="left: {Math.max(0, Math.min(mouseX + 12, width - 240))}px; top: {Math.max(0, Math.min(mouseY - 8, height - 120))}px;"
|
||||
>
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<div class="w-2 h-2 rounded-full" style="background: {nodeColor(hoverNode.type)}"></div>
|
||||
<span class="text-bright font-semibold">{hoverNode.type ?? 'memory'}</span>
|
||||
<span class="text-muted ml-auto">trust {(hoverNode.trust * 100).toFixed(0)}%</span>
|
||||
</div>
|
||||
<div class="text-text mb-1">{hoverNode.preview}</div>
|
||||
{#if hoverNode.created}
|
||||
<div class="text-muted text-[10px]">created {hoverNode.created}</div>
|
||||
{/if}
|
||||
{#if hoverNode.tags && hoverNode.tags.length > 0}
|
||||
<div class="text-muted text-[10px] mt-1">
|
||||
{hoverNode.tags.slice(0, 4).join(' · ')}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if hoverArc}
|
||||
<div
|
||||
class="pointer-events-none absolute z-10 glass-panel rounded-lg px-3 py-2 text-xs max-w-xs shadow-xl"
|
||||
style="left: {Math.max(0, Math.min(mouseX + 12, width - 240))}px; top: {Math.max(0, Math.min(mouseY - 8, height - 120))}px;"
|
||||
>
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<div class="w-2 h-2 rounded-full" style="background: {hoverArc.color}"></div>
|
||||
<span class="text-bright font-semibold">{hoverArc.severity} conflict</span>
|
||||
</div>
|
||||
<div class="text-dim">topic: <span class="text-text">{hoverArc.topic}</span></div>
|
||||
<div class="text-muted text-[10px] mt-1">
|
||||
similarity {(hoverArc.similarity * 100).toFixed(0)}% · {hoverArc.dateDiff}d apart
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes arc-drift {
|
||||
0% { stroke-dashoffset: 0; }
|
||||
100% { stroke-dashoffset: -32; }
|
||||
}
|
||||
:global(.arc-particle) {
|
||||
animation-name: arc-drift;
|
||||
animation-timing-function: linear;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
</style>
|
||||
211
apps/dashboard/src/lib/components/DreamInsightCard.svelte
Normal file
211
apps/dashboard/src/lib/components/DreamInsightCard.svelte
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
<!--
|
||||
DreamInsightCard — single insight from a dream cycle.
|
||||
High-novelty insights (>0.7) get a golden glow. Low-novelty (<0.3) muted.
|
||||
Source memory IDs are clickable → navigate to /memories/[id].
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { base } from '$app/paths';
|
||||
import type { DreamInsight } from '$types';
|
||||
import {
|
||||
clamp01,
|
||||
noveltyBand,
|
||||
formatConfidencePct,
|
||||
firstSourceIds,
|
||||
extraSourceCount,
|
||||
sourceMemoryHref,
|
||||
shortMemoryId,
|
||||
} from './dream-helpers';
|
||||
|
||||
interface Props {
|
||||
insight: DreamInsight;
|
||||
index?: number;
|
||||
}
|
||||
|
||||
let { insight, index = 0 }: Props = $props();
|
||||
|
||||
let novelty = $derived(clamp01(insight.noveltyScore));
|
||||
let confidence = $derived(clamp01(insight.confidence));
|
||||
let band = $derived(noveltyBand(insight.noveltyScore));
|
||||
let isHighNovelty = $derived(band === 'high');
|
||||
let isLowNovelty = $derived(band === 'low');
|
||||
let firstSources = $derived(firstSourceIds(insight.sourceMemories, 2));
|
||||
let extraCount = $derived(extraSourceCount(insight.sourceMemories, 2));
|
||||
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
connection: '#818cf8',
|
||||
pattern: '#ec4899',
|
||||
contradiction: '#ef4444',
|
||||
synthesis: '#c084fc',
|
||||
emergence: '#f59e0b',
|
||||
cluster: '#06b6d4'
|
||||
};
|
||||
|
||||
let typeColor = $derived(TYPE_COLORS[insight.type?.toLowerCase() ?? ''] ?? '#a855f7');
|
||||
</script>
|
||||
|
||||
<article
|
||||
class="insight-card glass-panel rounded-xl p-4 space-y-3"
|
||||
class:high-novelty={isHighNovelty}
|
||||
class:low-novelty={isLowNovelty}
|
||||
style="--insight-color: {typeColor}; --enter-delay: {index * 60}ms"
|
||||
>
|
||||
<!-- Type badge + novelty halo -->
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span
|
||||
class="text-[10px] uppercase tracking-[0.12em] font-semibold px-2 py-0.5 rounded-full"
|
||||
style="background: {typeColor}22; color: {typeColor}; border: 1px solid {typeColor}55"
|
||||
>
|
||||
{insight.type ?? 'insight'}
|
||||
</span>
|
||||
{#if isHighNovelty}
|
||||
<span class="text-[10px] text-warning font-semibold flex items-center gap-1">
|
||||
<span class="sparkle">✦</span> novel
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Insight text -->
|
||||
<p class="text-sm text-bright font-semibold leading-snug">
|
||||
{insight.insight}
|
||||
</p>
|
||||
|
||||
<!-- Novelty bar -->
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center justify-between text-[10px] text-dim uppercase tracking-wider">
|
||||
<span>Novelty</span>
|
||||
<span class="tabular-nums text-text/80">{novelty.toFixed(2)}</span>
|
||||
</div>
|
||||
<div class="novelty-track">
|
||||
<div
|
||||
class="novelty-fill"
|
||||
style="width: {novelty * 100}%; background: linear-gradient(90deg, {typeColor}, var(--color-dream-glow))"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Confidence -->
|
||||
<div class="flex items-center justify-between text-[11px]">
|
||||
<span class="text-dim">Confidence</span>
|
||||
<span
|
||||
class="tabular-nums font-semibold"
|
||||
style="color: {confidence > 0.7 ? '#10b981' : confidence > 0.4 ? '#f59e0b' : '#ef4444'}"
|
||||
>
|
||||
{formatConfidencePct(confidence)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Source memories -->
|
||||
{#if firstSources.length > 0}
|
||||
<div class="pt-2 border-t border-white/5 space-y-1.5">
|
||||
<div class="text-[10px] text-dim uppercase tracking-wider">
|
||||
Sources
|
||||
{#if extraCount > 0}
|
||||
<span class="text-muted">(+{extraCount})</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each firstSources as id (id)}
|
||||
<a
|
||||
href={sourceMemoryHref(id, base)}
|
||||
class="source-chip font-mono text-[10px] px-2 py-0.5 rounded"
|
||||
title="Open memory {id}"
|
||||
>
|
||||
{shortMemoryId(id)}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</article>
|
||||
|
||||
<style>
|
||||
.insight-card {
|
||||
position: relative;
|
||||
border: 1px solid color-mix(in srgb, var(--insight-color) 20%, transparent);
|
||||
transition: transform 400ms cubic-bezier(0.34, 1.56, 0.64, 1),
|
||||
border-color 220ms ease, box-shadow 220ms ease;
|
||||
animation: card-in 420ms cubic-bezier(0.34, 1.56, 0.64, 1) both;
|
||||
animation-delay: var(--enter-delay, 0ms);
|
||||
}
|
||||
|
||||
.insight-card:hover {
|
||||
transform: translateY(-2px) scale(1.01);
|
||||
border-color: color-mix(in srgb, var(--insight-color) 45%, transparent);
|
||||
}
|
||||
|
||||
.insight-card.high-novelty {
|
||||
border-color: rgba(245, 158, 11, 0.4);
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(245, 158, 11, 0.25),
|
||||
0 0 24px -4px rgba(245, 158, 11, 0.45),
|
||||
0 0 60px -12px rgba(245, 158, 11, 0.25),
|
||||
inset 0 1px 0 0 rgba(255, 255, 255, 0.05);
|
||||
background:
|
||||
radial-gradient(at top right, rgba(245, 158, 11, 0.08), transparent 50%),
|
||||
rgba(10, 10, 26, 0.8);
|
||||
}
|
||||
|
||||
.insight-card.low-novelty {
|
||||
opacity: 0.6;
|
||||
filter: saturate(0.7);
|
||||
}
|
||||
|
||||
.insight-card.low-novelty:hover {
|
||||
opacity: 0.9;
|
||||
filter: saturate(1);
|
||||
}
|
||||
|
||||
.novelty-track {
|
||||
height: 4px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.novelty-fill {
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
transition: width 600ms cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
box-shadow: 0 0 8px color-mix(in srgb, var(--insight-color) 60%, transparent);
|
||||
}
|
||||
|
||||
.source-chip {
|
||||
background: rgba(99, 102, 241, 0.12);
|
||||
border: 1px solid rgba(99, 102, 241, 0.25);
|
||||
color: var(--color-synapse-glow);
|
||||
text-decoration: none;
|
||||
transition: all 180ms ease;
|
||||
}
|
||||
|
||||
.source-chip:hover {
|
||||
background: rgba(99, 102, 241, 0.25);
|
||||
border-color: rgba(129, 140, 248, 0.5);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.sparkle {
|
||||
display: inline-block;
|
||||
animation: sparkle-spin 3s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes sparkle-spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes card-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px) scale(0.97);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.insight-card { animation: none; }
|
||||
.sparkle { animation: none; }
|
||||
}
|
||||
</style>
|
||||
539
apps/dashboard/src/lib/components/DreamStageReplay.svelte
Normal file
539
apps/dashboard/src/lib/components/DreamStageReplay.svelte
Normal file
|
|
@ -0,0 +1,539 @@
|
|||
<!--
|
||||
DreamStageReplay — Visual playback of a single dream-consolidation stage.
|
||||
|
||||
5 stages (per MemoryDreamer):
|
||||
1. Replay — floating memory cards arrange themselves into a grid
|
||||
2. Cross-reference — edges connect cards (SVG lines)
|
||||
3. Strengthen — cards pulse, brighten, gain glow
|
||||
4. Prune — low-retention cards fade & dissolve
|
||||
5. Transfer — cards migrate from episodic (left) → semantic (right)
|
||||
|
||||
Pure CSS transforms + SVG + animations. No Three.js.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { DreamResult } from '$types';
|
||||
import { clampStage } from './dream-helpers';
|
||||
|
||||
interface Props {
|
||||
stage: number; // 1..5
|
||||
dreamResult: DreamResult | null;
|
||||
}
|
||||
|
||||
let { stage, dreamResult }: Props = $props();
|
||||
|
||||
const STAGES = [
|
||||
{
|
||||
num: 1,
|
||||
name: 'Replay',
|
||||
color: '#818cf8',
|
||||
desc: 'Hippocampal replay: tagged memories surface for consolidation.'
|
||||
},
|
||||
{
|
||||
num: 2,
|
||||
name: 'Cross-reference',
|
||||
color: '#a855f7',
|
||||
desc: 'Semantic proximity check — new edges discovered across memories.'
|
||||
},
|
||||
{
|
||||
num: 3,
|
||||
name: 'Strengthen',
|
||||
color: '#c084fc',
|
||||
desc: 'Co-activated memories strengthen; FSRS stability grows.'
|
||||
},
|
||||
{
|
||||
num: 4,
|
||||
name: 'Prune',
|
||||
color: '#ef4444',
|
||||
desc: 'Low-retention redundant memories compressed or released.'
|
||||
},
|
||||
{
|
||||
num: 5,
|
||||
name: 'Transfer',
|
||||
color: '#10b981',
|
||||
desc: 'Episodic → semantic consolidation (hippocampus → cortex).'
|
||||
}
|
||||
];
|
||||
|
||||
// Lock stage to valid range (handles NaN / negatives / >5)
|
||||
let stageIdx = $derived(clampStage(stage));
|
||||
let current = $derived(STAGES[stageIdx - 1]);
|
||||
|
||||
// Derive card count from dream result (clamp 6..12 for visual density)
|
||||
let cardCount = $derived.by(() => {
|
||||
if (!dreamResult) return 8;
|
||||
const n = dreamResult.memoriesReplayed ?? 8;
|
||||
return Math.max(6, Math.min(12, n));
|
||||
});
|
||||
|
||||
// Connection count from dream result
|
||||
let connectionCount = $derived.by(() => {
|
||||
if (!dreamResult) return 5;
|
||||
const n = dreamResult.stats?.newConnectionsFound ?? 5;
|
||||
return Math.max(3, Math.min(cardCount, n));
|
||||
});
|
||||
|
||||
let strengthenedCount = $derived.by(() => {
|
||||
if (!dreamResult) return Math.ceil(cardCount * 0.5);
|
||||
const n = dreamResult.stats?.memoriesStrengthened ?? Math.ceil(cardCount * 0.5);
|
||||
return Math.max(1, Math.min(cardCount, n));
|
||||
});
|
||||
|
||||
let prunedCount = $derived.by(() => {
|
||||
if (!dreamResult) return Math.ceil(cardCount * 0.25);
|
||||
const n = dreamResult.stats?.memoriesCompressed ?? Math.ceil(cardCount * 0.25);
|
||||
return Math.max(1, Math.min(Math.floor(cardCount / 2), n));
|
||||
});
|
||||
|
||||
// Deterministic pseudo-random for card positions so stage changes don't re-randomize
|
||||
function seed(i: number, salt = 0): number {
|
||||
const x = Math.sin((i + 1) * 9301 + 49297 + salt * 233) * 233280;
|
||||
return x - Math.floor(x);
|
||||
}
|
||||
|
||||
// Layout: cards on a circle-ish grid in the center.
|
||||
// In stage 5, we'll override X based on transfer side.
|
||||
interface CardPos {
|
||||
id: number;
|
||||
x: number; // 0..100 percent
|
||||
y: number; // 0..100 percent
|
||||
pruned: boolean;
|
||||
strengthened: boolean;
|
||||
transferIsSemantic: boolean;
|
||||
}
|
||||
|
||||
let cards = $derived.by<CardPos[]>(() => {
|
||||
const arr: CardPos[] = [];
|
||||
const cols = Math.ceil(Math.sqrt(cardCount));
|
||||
const rows = Math.ceil(cardCount / cols);
|
||||
for (let i = 0; i < cardCount; i++) {
|
||||
const col = i % cols;
|
||||
const row = Math.floor(i / cols);
|
||||
const baseX = 20 + (col / Math.max(1, cols - 1)) * 60;
|
||||
const baseY = 20 + (row / Math.max(1, rows - 1)) * 60;
|
||||
// jitter
|
||||
const jx = (seed(i, 1) - 0.5) * 8;
|
||||
const jy = (seed(i, 2) - 0.5) * 8;
|
||||
arr.push({
|
||||
id: i,
|
||||
x: baseX + jx,
|
||||
y: baseY + jy,
|
||||
pruned: i < prunedCount,
|
||||
strengthened: i < strengthenedCount,
|
||||
transferIsSemantic: i % 2 === 0
|
||||
});
|
||||
}
|
||||
return arr;
|
||||
});
|
||||
|
||||
// Edges for stage 2 (and persist for 3+). Pick random pairs from available cards.
|
||||
interface Edge {
|
||||
a: number;
|
||||
b: number;
|
||||
}
|
||||
|
||||
let edges = $derived.by<Edge[]>(() => {
|
||||
const e: Edge[] = [];
|
||||
const n = cards.length;
|
||||
for (let k = 0; k < connectionCount; k++) {
|
||||
const a = Math.floor(seed(k, 7) * n);
|
||||
let b = Math.floor(seed(k, 11) * n);
|
||||
if (b === a) b = (a + 1) % n;
|
||||
e.push({ a, b });
|
||||
}
|
||||
return e;
|
||||
});
|
||||
|
||||
// Card displayed position depending on stage
|
||||
function cardX(card: CardPos): number {
|
||||
if (stageIdx === 5) {
|
||||
// Migrate: episodic (left 15..35%) → semantic (right 65..85%)
|
||||
const target = card.transferIsSemantic ? 75 : 25;
|
||||
return target + (seed(card.id, 5) - 0.5) * 12;
|
||||
}
|
||||
return card.x;
|
||||
}
|
||||
|
||||
function cardY(card: CardPos): number {
|
||||
if (stageIdx === 5) {
|
||||
return 25 + seed(card.id, 6) * 50;
|
||||
}
|
||||
return card.y;
|
||||
}
|
||||
|
||||
function cardOpacity(card: CardPos): number {
|
||||
if (stageIdx === 4 && card.pruned) return 0;
|
||||
if (stageIdx === 5 && card.pruned) return 0.15;
|
||||
return 1;
|
||||
}
|
||||
|
||||
function cardScale(card: CardPos): number {
|
||||
if (stageIdx === 3 && card.strengthened) return 1.18;
|
||||
if (stageIdx === 4 && card.pruned) return 0.6;
|
||||
return 1;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="replay-stage glass-panel rounded-2xl overflow-hidden relative">
|
||||
<!-- Stage header -->
|
||||
<header class="flex items-center justify-between px-5 py-3 border-b border-white/5 relative z-10">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="stage-badge w-9 h-9 rounded-full flex items-center justify-center font-mono font-bold text-sm"
|
||||
style="
|
||||
background: color-mix(in srgb, {current.color} 20%, transparent);
|
||||
color: {current.color};
|
||||
border: 1.5px solid {current.color};
|
||||
box-shadow: 0 0 16px color-mix(in srgb, {current.color} 40%, transparent);
|
||||
"
|
||||
>
|
||||
{current.num}
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-bright tracking-wide">{current.name}</div>
|
||||
<div class="text-[11px] text-dim leading-snug max-w-md">{current.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-[10px] text-dim uppercase tracking-[0.15em] hidden sm:block">
|
||||
Stage {current.num} / 5
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Stage canvas -->
|
||||
<div
|
||||
class="stage-canvas"
|
||||
style="--stage-color: {current.color}"
|
||||
aria-label="Dream stage {current.num} — {current.name}"
|
||||
>
|
||||
<!-- Left/right labels for transfer stage -->
|
||||
{#if stageIdx === 5}
|
||||
<div class="transfer-label episodic">
|
||||
<span class="label-tag">Episodic</span>
|
||||
<span class="label-sub">hippocampus</span>
|
||||
</div>
|
||||
<div class="transfer-label semantic">
|
||||
<span class="label-tag">Semantic</span>
|
||||
<span class="label-sub">cortex</span>
|
||||
</div>
|
||||
<div class="divider-line"></div>
|
||||
{/if}
|
||||
|
||||
<!-- SVG edges (visible from stage 2 onward) -->
|
||||
<svg
|
||||
class="edges-layer"
|
||||
viewBox="0 0 100 100"
|
||||
preserveAspectRatio="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{#each edges as edge, i (edge.a + '-' + edge.b + '-' + i)}
|
||||
{@const a = cards[edge.a]}
|
||||
{@const b = cards[edge.b]}
|
||||
{#if a && b}
|
||||
{@const x1 = cardX(a)}
|
||||
{@const y1 = cardY(a)}
|
||||
{@const x2 = cardX(b)}
|
||||
{@const y2 = cardY(b)}
|
||||
<line
|
||||
x1={x1}
|
||||
y1={y1}
|
||||
x2={x2}
|
||||
y2={y2}
|
||||
stroke={current.color}
|
||||
stroke-width={stageIdx === 2 ? 0.25 : stageIdx === 3 ? 0.35 : 0.2}
|
||||
stroke-opacity={stageIdx < 2 ? 0 : stageIdx === 4 ? 0.25 : stageIdx === 5 ? 0.15 : 0.6}
|
||||
stroke-dasharray={stageIdx === 2 ? '1.2 0.8' : 'none'}
|
||||
class="edge-line"
|
||||
style="--edge-delay: {i * 80}ms"
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
</svg>
|
||||
|
||||
<!-- Memory cards -->
|
||||
{#each cards as card (card.id)}
|
||||
<div
|
||||
class="memory-card"
|
||||
class:is-pulsing={stageIdx === 3 && card.strengthened}
|
||||
class:is-pruning={stageIdx === 4 && card.pruned}
|
||||
class:is-transferring={stageIdx === 5}
|
||||
class:semantic-side={stageIdx === 5 && card.transferIsSemantic}
|
||||
style="
|
||||
left: {cardX(card)}%;
|
||||
top: {cardY(card)}%;
|
||||
opacity: {cardOpacity(card)};
|
||||
--card-scale: {cardScale(card)};
|
||||
--card-delay: {card.id * 40}ms;
|
||||
--card-hue: {seed(card.id, 3) * 60 - 30}deg;
|
||||
"
|
||||
>
|
||||
<div class="card-inner">
|
||||
<div class="card-dot"></div>
|
||||
<div class="card-bar"></div>
|
||||
<div class="card-bar short"></div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<!-- Ambient pulse for stage 1 (replay) -->
|
||||
{#if stageIdx === 1}
|
||||
<div class="replay-pulse" aria-hidden="true"></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Stage footer with stats -->
|
||||
<footer class="flex flex-wrap gap-x-6 gap-y-1 px-5 py-3 border-t border-white/5 text-[11px] text-dim">
|
||||
{#if stageIdx === 1}
|
||||
<span>Replaying <b class="text-bright tabular-nums">{dreamResult?.memoriesReplayed ?? cardCount}</b> memories</span>
|
||||
{:else if stageIdx === 2}
|
||||
<span>New connections found: <b class="text-bright tabular-nums">{dreamResult?.stats?.newConnectionsFound ?? connectionCount}</b></span>
|
||||
{:else if stageIdx === 3}
|
||||
<span>Strengthened: <b class="text-bright tabular-nums">{dreamResult?.stats?.memoriesStrengthened ?? strengthenedCount}</b></span>
|
||||
{:else if stageIdx === 4}
|
||||
<span>Compressed: <b class="text-bright tabular-nums">{dreamResult?.stats?.memoriesCompressed ?? prunedCount}</b></span>
|
||||
{:else if stageIdx === 5}
|
||||
<span>Connections persisted: <b class="text-bright tabular-nums">{dreamResult?.connectionsPersisted ?? 0}</b></span>
|
||||
<span>Insights: <b class="text-bright tabular-nums">{dreamResult?.stats?.insightsGenerated ?? 0}</b></span>
|
||||
{/if}
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.replay-stage {
|
||||
border: 1px solid rgba(168, 85, 247, 0.18);
|
||||
box-shadow:
|
||||
inset 0 1px 0 0 rgba(255, 255, 255, 0.03),
|
||||
0 8px 36px -8px rgba(0, 0, 0, 0.55),
|
||||
0 0 48px -16px rgba(168, 85, 247, 0.25);
|
||||
}
|
||||
|
||||
.stage-canvas {
|
||||
position: relative;
|
||||
height: 360px;
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(at 50% 50%, color-mix(in srgb, var(--stage-color) 10%, transparent), transparent 60%),
|
||||
radial-gradient(at 20% 80%, rgba(99, 102, 241, 0.08), transparent 50%),
|
||||
#08081a;
|
||||
}
|
||||
|
||||
.edges-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.edge-line {
|
||||
transition: stroke-opacity 520ms ease, stroke-width 520ms ease,
|
||||
x1 600ms cubic-bezier(0.34, 1.56, 0.64, 1),
|
||||
y1 600ms cubic-bezier(0.34, 1.56, 0.64, 1),
|
||||
x2 600ms cubic-bezier(0.34, 1.56, 0.64, 1),
|
||||
y2 600ms cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.memory-card {
|
||||
position: absolute;
|
||||
width: 44px;
|
||||
height: 32px;
|
||||
transform: translate(-50%, -50%) scale(var(--card-scale, 1));
|
||||
transition:
|
||||
left 600ms cubic-bezier(0.34, 1.56, 0.64, 1),
|
||||
top 600ms cubic-bezier(0.34, 1.56, 0.64, 1),
|
||||
transform 500ms cubic-bezier(0.34, 1.56, 0.64, 1),
|
||||
opacity 500ms ease;
|
||||
transition-delay: var(--card-delay, 0ms);
|
||||
animation: card-float 6s ease-in-out infinite;
|
||||
animation-delay: var(--card-delay, 0ms);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.card-inner {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
color-mix(in srgb, var(--stage-color) 30%, transparent),
|
||||
color-mix(in srgb, var(--stage-color) 10%, transparent)
|
||||
);
|
||||
border: 1px solid color-mix(in srgb, var(--stage-color) 50%, transparent);
|
||||
box-shadow:
|
||||
inset 0 1px 0 0 rgba(255, 255, 255, 0.08),
|
||||
0 0 8px color-mix(in srgb, var(--stage-color) 30%, transparent);
|
||||
filter: hue-rotate(var(--card-hue, 0deg));
|
||||
padding: 5px 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-dot {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--stage-color) 90%, white);
|
||||
box-shadow: 0 0 6px var(--stage-color);
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
}
|
||||
|
||||
.card-bar {
|
||||
height: 3px;
|
||||
border-radius: 1.5px;
|
||||
background: color-mix(in srgb, var(--stage-color) 70%, transparent);
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
.card-bar.short {
|
||||
width: 50%;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.memory-card.is-pulsing {
|
||||
animation: card-float 6s ease-in-out infinite, card-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.memory-card.is-pulsing .card-inner {
|
||||
border-color: var(--color-dream-glow);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
color-mix(in srgb, var(--color-dream-glow) 40%, transparent),
|
||||
color-mix(in srgb, var(--color-dream) 25%, transparent)
|
||||
);
|
||||
box-shadow:
|
||||
inset 0 1px 0 0 rgba(255, 255, 255, 0.12),
|
||||
0 0 22px color-mix(in srgb, var(--color-dream-glow) 60%, transparent),
|
||||
0 0 44px color-mix(in srgb, var(--color-dream) 35%, transparent);
|
||||
}
|
||||
|
||||
.memory-card.is-pruning .card-inner {
|
||||
animation: dissolve 1.2s ease-out forwards;
|
||||
}
|
||||
|
||||
.memory-card.is-transferring .card-inner {
|
||||
border-color: #10b981;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(16, 185, 129, 0.35),
|
||||
rgba(16, 185, 129, 0.12)
|
||||
);
|
||||
box-shadow:
|
||||
inset 0 1px 0 0 rgba(255, 255, 255, 0.08),
|
||||
0 0 14px rgba(16, 185, 129, 0.5);
|
||||
}
|
||||
|
||||
.memory-card.is-transferring.semantic-side .card-inner {
|
||||
border-color: #c084fc;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(192, 132, 252, 0.35),
|
||||
rgba(168, 85, 247, 0.15)
|
||||
);
|
||||
box-shadow:
|
||||
inset 0 1px 0 0 rgba(255, 255, 255, 0.08),
|
||||
0 0 14px rgba(192, 132, 252, 0.5);
|
||||
}
|
||||
|
||||
.replay-pulse {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 60%;
|
||||
aspect-ratio: 1 / 1;
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, color-mix(in srgb, var(--stage-color) 25%, transparent), transparent 60%);
|
||||
filter: blur(30px);
|
||||
animation: pulse-in 3s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.transfer-label {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.transfer-label.episodic {
|
||||
left: 6%;
|
||||
}
|
||||
|
||||
.transfer-label.semantic {
|
||||
right: 6%;
|
||||
}
|
||||
|
||||
.label-tag {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
color: #e0e0ff;
|
||||
}
|
||||
|
||||
.transfer-label.episodic .label-tag {
|
||||
border-color: rgba(16, 185, 129, 0.5);
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.transfer-label.semantic .label-tag {
|
||||
border-color: rgba(192, 132, 252, 0.5);
|
||||
color: #c084fc;
|
||||
}
|
||||
|
||||
.label-sub {
|
||||
font-size: 9px;
|
||||
color: var(--color-dim);
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.divider-line {
|
||||
position: absolute;
|
||||
top: 15%;
|
||||
bottom: 15%;
|
||||
left: 50%;
|
||||
width: 1px;
|
||||
background: linear-gradient(180deg, transparent, rgba(168, 85, 247, 0.35), transparent);
|
||||
transform: translateX(-0.5px);
|
||||
}
|
||||
|
||||
@keyframes card-float {
|
||||
0%, 100% { translate: 0 0; }
|
||||
25% { translate: 2px -3px; }
|
||||
50% { translate: -2px 2px; }
|
||||
75% { translate: 3px 1px; }
|
||||
}
|
||||
|
||||
@keyframes card-pulse {
|
||||
0%, 100% { filter: brightness(1) hue-rotate(var(--card-hue, 0deg)); }
|
||||
50% { filter: brightness(1.3) hue-rotate(var(--card-hue, 0deg)); }
|
||||
}
|
||||
|
||||
@keyframes dissolve {
|
||||
0% { opacity: 1; transform: scale(1); filter: blur(0); }
|
||||
60% { opacity: 0.3; filter: blur(2px); }
|
||||
100% { opacity: 0; transform: scale(0.5); filter: blur(6px); }
|
||||
}
|
||||
|
||||
@keyframes pulse-in {
|
||||
0%, 100% { opacity: 0.3; transform: translate(-50%, -50%) scale(1); }
|
||||
50% { opacity: 0.7; transform: translate(-50%, -50%) scale(1.15); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.memory-card { animation: none; }
|
||||
.replay-pulse { animation: none; }
|
||||
.memory-card.is-pulsing { animation: none; }
|
||||
}
|
||||
</style>
|
||||
192
apps/dashboard/src/lib/components/DuplicateCluster.svelte
Normal file
192
apps/dashboard/src/lib/components/DuplicateCluster.svelte
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
<!--
|
||||
DuplicateCluster — renders a single cosine-similarity cluster from the
|
||||
`find_duplicates` MCP tool. Shows similarity bar (color-coded by severity),
|
||||
stacked memory cards with type/retention/tags/date, and action controls
|
||||
(Merge all → highest-retention winner, Review → expand, Dismiss → hide).
|
||||
|
||||
Pure helpers live in `./duplicates-helpers.ts` and are unit-tested there.
|
||||
Keep this file focused on rendering + glue.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { NODE_TYPE_COLORS } from '$types';
|
||||
import {
|
||||
similarityBandColor,
|
||||
similarityBandLabel,
|
||||
retentionColor,
|
||||
pickWinner,
|
||||
previewContent,
|
||||
formatDate,
|
||||
safeTags,
|
||||
} from './duplicates-helpers';
|
||||
|
||||
interface ClusterMemory {
|
||||
id: string;
|
||||
content: string;
|
||||
nodeType: string;
|
||||
tags: string[];
|
||||
retention: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
similarity: number;
|
||||
memories: ClusterMemory[];
|
||||
suggestedAction: 'merge' | 'review';
|
||||
onDismiss?: () => void;
|
||||
onMerge?: (winnerId: string, loserIds: string[]) => void;
|
||||
}
|
||||
|
||||
let { similarity, memories, suggestedAction, onDismiss, onMerge }: Props = $props();
|
||||
|
||||
let expanded = $state(false);
|
||||
|
||||
// Winner = highest retention; others get merged into it. Stable tie-break
|
||||
// (first-wins). pickWinner returns null for empty input — render-guarded below.
|
||||
const winner = $derived(pickWinner(memories));
|
||||
const losers = $derived(
|
||||
winner ? memories.filter((m) => m.id !== winner.id).map((m) => m.id) : []
|
||||
);
|
||||
|
||||
function handleMerge() {
|
||||
if (onMerge && winner) onMerge(winner.id, losers);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if memories.length > 0 && winner}
|
||||
<div
|
||||
class="glass-panel rounded-2xl p-5 space-y-4 transition-all duration-300 hover:border-synapse/20"
|
||||
>
|
||||
<!-- Header row: similarity bar + suggested action badge -->
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex-1 min-w-0 space-y-1.5">
|
||||
<div class="flex items-center gap-3">
|
||||
<span
|
||||
class="text-sm font-semibold"
|
||||
style="color: {similarityBandColor(similarity)}"
|
||||
>
|
||||
{(similarity * 100).toFixed(1)}%
|
||||
</span>
|
||||
<span class="text-xs text-dim">{similarityBandLabel(similarity)}</span>
|
||||
<span class="text-xs text-muted">· {memories.length} memories</span>
|
||||
</div>
|
||||
<div
|
||||
class="h-2 w-full overflow-hidden rounded-full bg-deep/60"
|
||||
role="progressbar"
|
||||
aria-label="Cosine similarity"
|
||||
aria-valuenow={Math.round(similarity * 100)}
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
>
|
||||
<div
|
||||
class="h-full rounded-full transition-all duration-500"
|
||||
style="width: {(similarity * 100).toFixed(1)}%; background: {similarityBandColor(
|
||||
similarity
|
||||
)}; box-shadow: 0 0 12px {similarityBandColor(similarity)}66"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Suggested action badge — dream (review) or recall (merge) -->
|
||||
<span
|
||||
class="flex-shrink-0 rounded-full border px-3 py-1 text-xs font-medium {suggestedAction ===
|
||||
'merge'
|
||||
? 'border-recall/40 bg-recall/10 text-recall'
|
||||
: 'border-dream-glow/40 bg-dream/10 text-dream-glow'}"
|
||||
>
|
||||
Suggested: {suggestedAction === 'merge' ? 'Merge' : 'Review'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Stacked memory cards -->
|
||||
<div class="space-y-2">
|
||||
{#each memories as memory (memory.id)}
|
||||
<div
|
||||
class="group flex items-start gap-3 rounded-xl border border-synapse/5 bg-white/[0.02] p-3 transition-all duration-200 hover:border-synapse/20 hover:bg-white/[0.04] {memory.id ===
|
||||
winner.id
|
||||
? 'ring-1 ring-recall/30'
|
||||
: ''}"
|
||||
>
|
||||
<!-- Type dot -->
|
||||
<span
|
||||
class="mt-1.5 h-2 w-2 flex-shrink-0 rounded-full"
|
||||
style="background: {NODE_TYPE_COLORS[memory.nodeType] || '#8B95A5'}"
|
||||
title={memory.nodeType}
|
||||
></span>
|
||||
|
||||
<div class="flex-1 min-w-0 space-y-1.5">
|
||||
<!-- Type + tags + winner flag -->
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<span class="text-xs text-dim">{memory.nodeType}</span>
|
||||
{#if memory.id === winner.id}
|
||||
<span class="rounded bg-recall/15 px-1.5 py-0.5 text-[10px] font-medium text-recall">
|
||||
WINNER
|
||||
</span>
|
||||
{/if}
|
||||
{#each safeTags(memory.tags, 4) as tag}
|
||||
<span class="rounded bg-white/[0.04] px-1.5 py-0.5 text-[10px] text-muted"
|
||||
>{tag}</span
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Content preview (or full content if expanded) -->
|
||||
<p class="text-sm text-text leading-relaxed {expanded ? 'whitespace-pre-wrap' : ''}">
|
||||
{expanded ? memory.content : previewContent(memory.content)}
|
||||
</p>
|
||||
|
||||
<!-- Date (empty string for invalid/missing — no "Invalid Date") -->
|
||||
{#if formatDate(memory.createdAt)}
|
||||
<div class="text-[11px] text-muted">
|
||||
{formatDate(memory.createdAt)}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Retention bar + percent (right rail) -->
|
||||
<div class="flex flex-shrink-0 flex-col items-end gap-1">
|
||||
<div class="h-1.5 w-12 overflow-hidden rounded-full bg-deep">
|
||||
<div
|
||||
class="h-full rounded-full"
|
||||
style="width: {memory.retention * 100}%; background: {retentionColor(
|
||||
memory.retention
|
||||
)}"
|
||||
></div>
|
||||
</div>
|
||||
<span class="text-[11px] text-muted">
|
||||
{(memory.retention * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Actions — native <button> elements, fully keyboard-accessible. -->
|
||||
<div class="flex flex-wrap items-center gap-2 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleMerge}
|
||||
aria-label="Merge all memories into the highest-retention winner"
|
||||
class="rounded-lg bg-recall/20 px-3 py-1.5 text-xs font-medium text-recall transition hover:bg-recall/30 focus:outline-none focus-visible:ring-2 focus-visible:ring-recall/60"
|
||||
title="Merge all into highest-retention memory ({(winner.retention * 100).toFixed(0)}%)"
|
||||
>
|
||||
Merge all → winner
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (expanded = !expanded)}
|
||||
aria-expanded={expanded}
|
||||
class="rounded-lg bg-dream/20 px-3 py-1.5 text-xs font-medium text-dream-glow transition hover:bg-dream/30 focus:outline-none focus-visible:ring-2 focus-visible:ring-dream-glow/60"
|
||||
>
|
||||
{expanded ? 'Collapse' : 'Review'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onDismiss}
|
||||
aria-label="Dismiss cluster for this session"
|
||||
class="ml-auto rounded-lg bg-white/[0.04] px-3 py-1.5 text-xs text-dim transition hover:bg-white/[0.08] hover:text-text focus:outline-none focus-visible:ring-2 focus-visible:ring-synapse/60"
|
||||
>
|
||||
Dismiss cluster
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
157
apps/dashboard/src/lib/components/EvidenceCard.svelte
Normal file
157
apps/dashboard/src/lib/components/EvidenceCard.svelte
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
<script lang="ts">
|
||||
import {
|
||||
roleMetaFor,
|
||||
trustColor,
|
||||
trustPercent,
|
||||
nodeTypeColor,
|
||||
formatDate,
|
||||
shortenId,
|
||||
type EvidenceRole,
|
||||
} from './reasoning-helpers';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
trust: number; // 0-1
|
||||
date: string; // ISO
|
||||
role: EvidenceRole;
|
||||
preview: string;
|
||||
nodeType?: string;
|
||||
index?: number; // for staggered animation
|
||||
}
|
||||
|
||||
let { id, trust, date, role, preview, nodeType, index = 0 }: Props = $props();
|
||||
|
||||
// Clamp for display — delegated to pure helper for testability.
|
||||
const trustPct = $derived(trustPercent(trust));
|
||||
const meta = $derived(roleMetaFor(role));
|
||||
const shortId = $derived(shortenId(id));
|
||||
const typeColor = $derived(nodeTypeColor(nodeType));
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="evidence-card glass rounded-xl p-4 space-y-3 transition relative"
|
||||
class:contradicting={role === 'contradicting'}
|
||||
class:primary={role === 'primary'}
|
||||
class:superseded={role === 'superseded'}
|
||||
style="animation-delay: {index * 80}ms;"
|
||||
data-evidence-id={id}
|
||||
>
|
||||
<!-- Role banner + id -->
|
||||
<div class="flex items-center justify-between text-[10px] uppercase tracking-wider">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="role-pill px-2 py-0.5 rounded text-[10px]">
|
||||
<span class="mr-1">{meta.icon}</span>{meta.label}
|
||||
</span>
|
||||
{#if nodeType}
|
||||
<span class="px-1.5 py-0.5 rounded bg-white/[0.04]" style="color: {typeColor}">
|
||||
{nodeType}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="text-muted font-mono text-[10px]" title={id}>#{shortId}</span>
|
||||
</div>
|
||||
|
||||
<!-- Preview -->
|
||||
<p class="text-sm text-text leading-relaxed line-clamp-4">{preview}</p>
|
||||
|
||||
<!-- Trust bar -->
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center justify-between text-[10px]">
|
||||
<span class="text-dim uppercase tracking-wider">Trust</span>
|
||||
<span class="font-mono" style="color: {trustColor(trust)}">{trustPct.toFixed(0)}%</span>
|
||||
</div>
|
||||
<div class="h-1.5 bg-deep rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full transition-all duration-700 trust-fill"
|
||||
style="width: {trustPct}%; background: {trustColor(trust)}; box-shadow: 0 0 8px {trustColor(trust)}80;"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Date -->
|
||||
<div class="flex items-center justify-between text-[10px] text-muted pt-1">
|
||||
<span>{formatDate(date)}</span>
|
||||
<span class="font-mono opacity-60">FSRS · reps × retention</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.evidence-card {
|
||||
animation: card-rise 600ms cubic-bezier(0.22, 0.8, 0.3, 1) backwards;
|
||||
}
|
||||
|
||||
.evidence-card.primary {
|
||||
border-color: rgba(99, 102, 241, 0.35) !important;
|
||||
box-shadow:
|
||||
inset 0 1px 0 0 rgba(255, 255, 255, 0.04),
|
||||
0 0 32px rgba(99, 102, 241, 0.18),
|
||||
0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.evidence-card.contradicting {
|
||||
border-color: rgba(239, 68, 68, 0.45) !important;
|
||||
box-shadow:
|
||||
inset 0 1px 0 0 rgba(255, 255, 255, 0.03),
|
||||
0 0 28px rgba(239, 68, 68, 0.2),
|
||||
0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.evidence-card.superseded {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.evidence-card.superseded:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.role-pill {
|
||||
background: rgba(99, 102, 241, 0.12);
|
||||
color: #c7cbff;
|
||||
border: 1px solid rgba(99, 102, 241, 0.25);
|
||||
}
|
||||
|
||||
.evidence-card.contradicting .role-pill {
|
||||
background: rgba(239, 68, 68, 0.14);
|
||||
color: #fecaca;
|
||||
border-color: rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
|
||||
.evidence-card.primary .role-pill {
|
||||
background: rgba(99, 102, 241, 0.22);
|
||||
color: #a5b4ff;
|
||||
border-color: rgba(99, 102, 241, 0.5);
|
||||
}
|
||||
|
||||
.trust-fill {
|
||||
animation: trust-sweep 1000ms cubic-bezier(0.22, 0.8, 0.3, 1) backwards;
|
||||
}
|
||||
|
||||
@keyframes card-rise {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(12px) scale(0.98);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes trust-sweep {
|
||||
0% {
|
||||
width: 0% !important;
|
||||
opacity: 0.4;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.line-clamp-4 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 4;
|
||||
line-clamp: 4;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
344
apps/dashboard/src/lib/components/FSRSCalendar.svelte
Normal file
344
apps/dashboard/src/lib/components/FSRSCalendar.svelte
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
<script lang="ts">
|
||||
import type { Memory } from '$types';
|
||||
import { NODE_TYPE_COLORS } from '$types';
|
||||
import {
|
||||
startOfDay,
|
||||
daysBetween,
|
||||
isoDate,
|
||||
gridStartForAnchor,
|
||||
avgRetention as avgRetentionHelper,
|
||||
} from './schedule-helpers';
|
||||
|
||||
type Props = {
|
||||
memories: Memory[];
|
||||
anchor?: Date;
|
||||
};
|
||||
|
||||
let { memories, anchor = new Date() }: Props = $props();
|
||||
|
||||
// Build a 6-row x 7-col grid starting 2 weeks before today.
|
||||
// Rows: 2 past weeks + 4 future weeks = 6 weeks = 42 cells.
|
||||
// Align so the first row starts on the Sunday that is on or before
|
||||
// (today - 14 days). This keeps today visible in week 3.
|
||||
let today = $derived(startOfDay(anchor));
|
||||
let gridStart = $derived(gridStartForAnchor(anchor));
|
||||
|
||||
type DayCell = {
|
||||
date: Date;
|
||||
key: string;
|
||||
isToday: boolean;
|
||||
inWindow: boolean; // within -14 to +28 days
|
||||
memories: Memory[];
|
||||
avgRetention: number;
|
||||
};
|
||||
|
||||
// Bucket memories by their nextReviewAt day (YYYY-MM-DD).
|
||||
let buckets = $derived(
|
||||
(() => {
|
||||
const map = new Map<string, Memory[]>();
|
||||
for (const m of memories) {
|
||||
if (!m.nextReviewAt) continue;
|
||||
const d = new Date(m.nextReviewAt);
|
||||
if (Number.isNaN(d.getTime())) continue;
|
||||
const key = isoDate(startOfDay(d));
|
||||
const arr = map.get(key);
|
||||
if (arr) arr.push(m);
|
||||
else map.set(key, [m]);
|
||||
}
|
||||
return map;
|
||||
})()
|
||||
);
|
||||
|
||||
let cells = $derived(
|
||||
(() => {
|
||||
const out: DayCell[] = [];
|
||||
for (let i = 0; i < 42; i++) {
|
||||
const d = new Date(gridStart);
|
||||
d.setDate(d.getDate() + i);
|
||||
const key = isoDate(d);
|
||||
const ms = buckets.get(key) ?? [];
|
||||
const delta = daysBetween(d, today);
|
||||
out.push({
|
||||
date: d,
|
||||
key,
|
||||
isToday: delta === 0,
|
||||
inWindow: delta >= -14 && delta <= 28,
|
||||
memories: ms,
|
||||
avgRetention: avgRetentionHelper(ms)
|
||||
});
|
||||
}
|
||||
return out;
|
||||
})()
|
||||
);
|
||||
|
||||
// Urgency coloring — emerald / amber / decay-red / synapse / muted.
|
||||
function cellColor(cell: DayCell): { bg: string; border: string; text: string } {
|
||||
if (cell.memories.length === 0) {
|
||||
return { bg: 'rgba(255,255,255,0.02)', border: 'rgba(99,102,241,0.06)', text: '#4a4a7a' };
|
||||
}
|
||||
const delta = daysBetween(cell.date, today);
|
||||
if (delta < -1) {
|
||||
// Overdue (more than 1 day in the past) — decay-red
|
||||
return {
|
||||
bg: 'rgba(239,68,68,0.16)',
|
||||
border: 'rgba(239,68,68,0.45)',
|
||||
text: '#fca5a5'
|
||||
};
|
||||
}
|
||||
if (delta >= -1 && delta <= 0) {
|
||||
// Due today (or yesterday, just at the threshold) — amber
|
||||
return {
|
||||
bg: 'rgba(245,158,11,0.18)',
|
||||
border: 'rgba(245,158,11,0.5)',
|
||||
text: '#fcd34d'
|
||||
};
|
||||
}
|
||||
if (delta > 0 && delta <= 7) {
|
||||
// Due within 7 days — synapse blue
|
||||
return {
|
||||
bg: 'rgba(99,102,241,0.16)',
|
||||
border: 'rgba(99,102,241,0.45)',
|
||||
text: '#a5b4fc'
|
||||
};
|
||||
}
|
||||
// >7 days out — muted
|
||||
return {
|
||||
bg: 'rgba(168,85,247,0.08)',
|
||||
border: 'rgba(168,85,247,0.2)',
|
||||
text: '#c084fc'
|
||||
};
|
||||
}
|
||||
|
||||
let selectedKey: string | null = $state(null);
|
||||
let selectedCell = $derived(cells.find((c) => c.key === selectedKey) ?? null);
|
||||
|
||||
function toggle(key: string) {
|
||||
selectedKey = selectedKey === key ? null : key;
|
||||
}
|
||||
|
||||
// Retention sparkline — one point per day in the window, average retention
|
||||
// of memories due that day. Width 100% x height 48px SVG.
|
||||
const SPARK_W = 600;
|
||||
const SPARK_H = 56;
|
||||
|
||||
let sparkPoints = $derived(
|
||||
(() => {
|
||||
const pts: { x: number; y: number; r: number; count: number }[] = [];
|
||||
const n = cells.length;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const c = cells[i];
|
||||
const x = (i / (n - 1)) * SPARK_W;
|
||||
// Invert Y: higher retention = higher on chart = smaller y
|
||||
const r = c.avgRetention;
|
||||
const y = SPARK_H - 6 - r * (SPARK_H - 12);
|
||||
pts.push({ x, y, r, count: c.memories.length });
|
||||
}
|
||||
return pts;
|
||||
})()
|
||||
);
|
||||
|
||||
let sparkPath = $derived(
|
||||
(() => {
|
||||
const valid = sparkPoints.filter((p) => p.count > 0);
|
||||
if (valid.length === 0) return '';
|
||||
return valid.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x.toFixed(1)} ${p.y.toFixed(1)}`).join(' ');
|
||||
})()
|
||||
);
|
||||
|
||||
// Today's x-position on the sparkline, so the viewer can anchor the trend.
|
||||
let todayIndex = $derived(cells.findIndex((c) => c.isToday));
|
||||
let todayX = $derived(todayIndex >= 0 ? (todayIndex / (cells.length - 1)) * SPARK_W : -1);
|
||||
|
||||
const DOW_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
|
||||
function shortDate(d: Date): string {
|
||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
function fullDate(d: Date): string {
|
||||
return d.toLocaleDateString(undefined, {
|
||||
weekday: 'long',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Retention sparkline -->
|
||||
<div class="p-4 glass-subtle rounded-xl">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs text-dim font-medium">Avg retention of memories due — last 2 weeks → next 4</span>
|
||||
<div class="flex items-center gap-3 text-[10px] text-muted">
|
||||
<span class="flex items-center gap-1"><span class="w-2 h-0.5 bg-recall"></span>retention</span>
|
||||
<span class="flex items-center gap-1"><span class="w-px h-3 bg-synapse-glow"></span>today</span>
|
||||
</div>
|
||||
</div>
|
||||
<svg viewBox="0 0 {SPARK_W} {SPARK_H}" preserveAspectRatio="none" class="w-full h-12 block" aria-hidden="true">
|
||||
<!-- Baselines at 30% / 70% -->
|
||||
<line x1="0" x2={SPARK_W} y1={SPARK_H - 6 - 0.3 * (SPARK_H - 12)} y2={SPARK_H - 6 - 0.3 * (SPARK_H - 12)}
|
||||
stroke="rgba(239,68,68,0.18)" stroke-dasharray="2 4" stroke-width="1" />
|
||||
<line x1="0" x2={SPARK_W} y1={SPARK_H - 6 - 0.7 * (SPARK_H - 12)} y2={SPARK_H - 6 - 0.7 * (SPARK_H - 12)}
|
||||
stroke="rgba(16,185,129,0.18)" stroke-dasharray="2 4" stroke-width="1" />
|
||||
{#if todayX >= 0}
|
||||
<line x1={todayX} x2={todayX} y1="0" y2={SPARK_H}
|
||||
stroke="rgba(129,140,248,0.5)" stroke-width="1" />
|
||||
{/if}
|
||||
{#if sparkPath}
|
||||
<path d={sparkPath} fill="none" stroke="var(--color-recall)" stroke-width="1.5" stroke-linejoin="round" />
|
||||
{/if}
|
||||
{#each sparkPoints as p}
|
||||
{#if p.count > 0}
|
||||
<circle cx={p.x} cy={p.y} r="1.5" fill="var(--color-recall)" />
|
||||
{/if}
|
||||
{/each}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Day-of-week header -->
|
||||
<div class="grid grid-cols-7 gap-2 px-1">
|
||||
{#each DOW_LABELS as label}
|
||||
<div class="text-[10px] text-muted font-mono uppercase tracking-wider text-center">{label}</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Calendar grid -->
|
||||
<div class="grid grid-cols-7 gap-2">
|
||||
{#each cells as cell (cell.key)}
|
||||
{@const colors = cellColor(cell)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => toggle(cell.key)}
|
||||
disabled={cell.memories.length === 0}
|
||||
class="relative aspect-square rounded-lg p-2 text-left transition-all duration-200
|
||||
{cell.inWindow ? 'opacity-100' : 'opacity-35'}
|
||||
{cell.memories.length > 0 ? 'hover:scale-[1.03] cursor-pointer' : 'cursor-default'}
|
||||
{cell.isToday ? 'ring-2 ring-synapse/60 shadow-[0_0_16px_rgba(99,102,241,0.3)]' : ''}
|
||||
{selectedKey === cell.key ? 'ring-2 ring-dream/60 shadow-[0_0_16px_rgba(168,85,247,0.3)]' : ''}"
|
||||
style="background: {colors.bg}; border: 1px solid {colors.border};"
|
||||
title={`${fullDate(cell.date)} — ${cell.memories.length} due`}
|
||||
>
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="flex items-start justify-between">
|
||||
<span class="text-[10px] font-mono {cell.isToday ? 'text-synapse-glow font-bold' : 'text-dim'}">
|
||||
{cell.date.getDate()}
|
||||
</span>
|
||||
{#if cell.date.getDate() === 1}
|
||||
<span class="text-[9px] text-muted">{cell.date.toLocaleDateString(undefined, { month: 'short' })}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if cell.memories.length > 0}
|
||||
<div class="flex-1 flex flex-col items-center justify-center gap-0.5">
|
||||
<span class="text-base sm:text-lg font-bold leading-none" style="color: {colors.text}">
|
||||
{cell.memories.length}
|
||||
</span>
|
||||
{#if cell.avgRetention > 0}
|
||||
<span class="text-[9px] text-muted">{(cell.avgRetention * 100).toFixed(0)}%</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Legend -->
|
||||
<div class="flex items-center gap-4 text-[10px] text-muted flex-wrap px-1">
|
||||
<span class="flex items-center gap-1.5">
|
||||
<span class="w-3 h-3 rounded" style="background: rgba(239,68,68,0.16); border: 1px solid rgba(239,68,68,0.45);"></span>
|
||||
Overdue
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<span class="w-3 h-3 rounded" style="background: rgba(245,158,11,0.18); border: 1px solid rgba(245,158,11,0.5);"></span>
|
||||
Due today
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<span class="w-3 h-3 rounded" style="background: rgba(99,102,241,0.16); border: 1px solid rgba(99,102,241,0.45);"></span>
|
||||
Within 7 days
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<span class="w-3 h-3 rounded" style="background: rgba(168,85,247,0.08); border: 1px solid rgba(168,85,247,0.2);"></span>
|
||||
Future (8+ days)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Expanded day panel -->
|
||||
{#if selectedCell && selectedCell.memories.length > 0}
|
||||
<div class="p-5 glass rounded-xl space-y-3 animate-panel-in">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-sm text-bright font-semibold">{fullDate(selectedCell.date)}</h3>
|
||||
<p class="text-xs text-dim mt-0.5">
|
||||
{selectedCell.memories.length} memor{selectedCell.memories.length === 1 ? 'y' : 'ies'} due
|
||||
· avg retention {(selectedCell.avgRetention * 100).toFixed(0)}%
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (selectedKey = null)}
|
||||
class="text-xs text-muted hover:text-dim px-2 py-1 rounded-lg hover:bg-white/[0.03]"
|
||||
aria-label="Close"
|
||||
>
|
||||
close ×
|
||||
</button>
|
||||
</div>
|
||||
<div class="space-y-2 max-h-96 overflow-y-auto pr-1">
|
||||
{#each selectedCell.memories.slice(0, 100) as m (m.id)}
|
||||
<div class="flex items-start gap-3 p-2.5 rounded-lg bg-white/[0.02] hover:bg-white/[0.04] transition">
|
||||
<span
|
||||
class="w-2 h-2 mt-1.5 rounded-full flex-shrink-0"
|
||||
style="background: {NODE_TYPE_COLORS[m.nodeType] || '#8B95A5'}"
|
||||
></span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm text-text leading-snug line-clamp-2">{m.content}</p>
|
||||
<div class="flex items-center gap-2 mt-1 text-[10px] text-muted">
|
||||
<span>{m.nodeType}</span>
|
||||
{#if m.reviewCount !== undefined}
|
||||
<span>· {m.reviewCount} review{m.reviewCount === 1 ? '' : 's'}</span>
|
||||
{/if}
|
||||
{#each m.tags.slice(0, 2) as tag}
|
||||
<span class="px-1 py-0.5 bg-white/[0.04] rounded text-muted">{tag}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col items-end gap-1 flex-shrink-0">
|
||||
<div class="w-12 h-1 bg-deep rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full"
|
||||
style="width: {m.retentionStrength * 100}%; background: {m.retentionStrength > 0.7
|
||||
? 'var(--color-recall)'
|
||||
: m.retentionStrength > 0.4
|
||||
? 'var(--color-warning)'
|
||||
: 'var(--color-decay)'}"
|
||||
></div>
|
||||
</div>
|
||||
<span class="text-[10px] text-muted">{(m.retentionStrength * 100).toFixed(0)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{#if selectedCell.memories.length > 100}
|
||||
<p class="text-xs text-muted text-center pt-2">
|
||||
+{selectedCell.memories.length - 100} more
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes panel-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
.animate-panel-in {
|
||||
animation: panel-in 0.18s ease-out;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -11,6 +11,7 @@
|
|||
import { mapEventToEffects, type GraphMutationContext, type GraphMutation } from '$lib/graph/events';
|
||||
import { createNebulaBackground, updateNebula } from '$lib/graph/shaders/nebula.frag';
|
||||
import { createPostProcessing, updatePostProcessing, type PostProcessingStack } from '$lib/graph/shaders/post-processing';
|
||||
import { graphState } from '$lib/stores/graph-state.svelte';
|
||||
import type * as THREE from 'three';
|
||||
|
||||
interface Props {
|
||||
|
|
@ -59,8 +60,13 @@
|
|||
let nebulaMaterial: THREE.ShaderMaterial;
|
||||
let postStack: PostProcessingStack;
|
||||
|
||||
// Event tracking
|
||||
let processedEventCount = 0;
|
||||
// Event tracking — we track the last-processed event by reference identity
|
||||
// rather than by count, because the WebSocket store PREPENDS new events
|
||||
// at index 0 and CAPS the array at MAX_EVENTS, so a numeric high-water
|
||||
// mark would drift out of alignment (and did for ~3 versions — v2.3
|
||||
// demo uncovered this while trying to fire multiple MemoryCreated events
|
||||
// in sequence).
|
||||
let lastProcessedEvent: VestigeEvent | null = null;
|
||||
|
||||
// Internal tracking: initial nodes + live-added nodes
|
||||
let allNodes: GraphNode[] = [];
|
||||
|
|
@ -116,9 +122,23 @@
|
|||
if (ctx) disposeScene(ctx);
|
||||
});
|
||||
|
||||
// 120Hz Governor. All physics and effect counters are frame-based
|
||||
// (orb.age++, forceSim.tick, materialization frames). On a ProMotion
|
||||
// display the browser drives rAF at 120 FPS, which would double-speed
|
||||
// every ritual. Clamping to ~60 FPS keeps the visual timing identical
|
||||
// across displays without rewriting every counter to use delta time.
|
||||
// The `- (dt % 16)` carry avoids long-term drift.
|
||||
let govLastTime = 0;
|
||||
|
||||
function animate() {
|
||||
animationId = requestAnimationFrame(animate);
|
||||
const time = performance.now() * 0.001;
|
||||
const now = performance.now();
|
||||
if (govLastTime === 0) govLastTime = now;
|
||||
const dt = now - govLastTime;
|
||||
if (dt < 16) return;
|
||||
govLastTime = now - (dt % 16);
|
||||
|
||||
const time = now * 0.001;
|
||||
|
||||
// Force simulation
|
||||
forceSim.tick(edges);
|
||||
|
|
@ -132,7 +152,7 @@
|
|||
|
||||
// Animate
|
||||
particles.animate(time);
|
||||
nodeManager.animate(time, allNodes, ctx.camera);
|
||||
nodeManager.animate(time, allNodes, ctx.camera, graphState.brightness);
|
||||
|
||||
// Dream mode
|
||||
dreamMode.setActive(isDreaming);
|
||||
|
|
@ -157,10 +177,33 @@
|
|||
}
|
||||
|
||||
function processEvents() {
|
||||
if (!events || events.length <= processedEventCount) return;
|
||||
if (!events || events.length === 0) return;
|
||||
|
||||
const newEvents = events.slice(processedEventCount);
|
||||
processedEventCount = events.length;
|
||||
// Walk the feed from newest (index 0) backward until we hit the last
|
||||
// event we already processed. Everything between is fresh. This is
|
||||
// robust against both (a) prepend ordering and (b) the MAX_EVENTS cap
|
||||
// dropping old entries off the tail.
|
||||
const fresh: VestigeEvent[] = [];
|
||||
for (const e of events) {
|
||||
if (e === lastProcessedEvent) break;
|
||||
fresh.push(e);
|
||||
}
|
||||
if (fresh.length === 0) return;
|
||||
|
||||
// Event Horizon Guard. If the last-processed reference fell off the
|
||||
// end of the capped array (burst of >MAX_EVENTS events in one tick),
|
||||
// the walk above consumed the ENTIRE buffer — we'd try to animate
|
||||
// 200 simultaneous births and melt the GPU. Detect the overflow and
|
||||
// drop this batch on the floor; state is already current via
|
||||
// lastProcessedEvent pointing forward.
|
||||
if (fresh.length === events.length && events.length >= 200) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[vestige] Event horizon overflow: dropping visuals for', fresh.length, 'events');
|
||||
lastProcessedEvent = events[0];
|
||||
return;
|
||||
}
|
||||
|
||||
lastProcessedEvent = events[0];
|
||||
|
||||
const mutationCtx: GraphMutationContext = {
|
||||
effects,
|
||||
|
|
@ -180,8 +223,11 @@
|
|||
},
|
||||
};
|
||||
|
||||
for (const event of newEvents) {
|
||||
mapEventToEffects(event, mutationCtx, allNodes);
|
||||
// Process oldest-first so cause precedes effect (e.g. MemoryCreated
|
||||
// fires before a ConnectionDiscovered that references the new node).
|
||||
// `fresh` is newest-first from the walk above, so iterate reversed.
|
||||
for (let i = fresh.length - 1; i >= 0; i--) {
|
||||
mapEventToEffects(fresh[i], mutationCtx, allNodes);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
174
apps/dashboard/src/lib/components/ImportanceRadar.svelte
Normal file
174
apps/dashboard/src/lib/components/ImportanceRadar.svelte
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import {
|
||||
AXIS_ORDER,
|
||||
clampChannels,
|
||||
radarRadius,
|
||||
sizePreset,
|
||||
} from './importance-helpers';
|
||||
|
||||
interface Props {
|
||||
novelty: number;
|
||||
arousal: number;
|
||||
reward: number;
|
||||
attention: number;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
let { novelty, arousal, reward, attention, size = 'md' }: Props = $props();
|
||||
|
||||
// Size presets + padding + clamp logic live in `importance-helpers.ts` so
|
||||
// they can be tested in the Vitest node env without a jsdom harness.
|
||||
let pxSize = $derived(sizePreset(size));
|
||||
let showLabels = $derived(size !== 'sm');
|
||||
let radius = $derived(radarRadius(size));
|
||||
let cx = $derived(pxSize / 2);
|
||||
let cy = $derived(pxSize / 2);
|
||||
|
||||
// Axis labels live alongside AXIS_ORDER's keys/angles so the renderer
|
||||
// never drifts from the helper geometry.
|
||||
const AXIS_LABELS: Record<(typeof AXIS_ORDER)[number]['key'], string> = {
|
||||
novelty: 'Novelty',
|
||||
arousal: 'Arousal',
|
||||
reward: 'Reward',
|
||||
attention: 'Attention'
|
||||
};
|
||||
|
||||
let values = $derived(clampChannels({ novelty, arousal, reward, attention }));
|
||||
|
||||
function pointAt(value: number, angle: number): [number, number] {
|
||||
const r = value * radius;
|
||||
return [cx + Math.cos(angle) * r, cy + Math.sin(angle) * r];
|
||||
}
|
||||
|
||||
// Grid rings at 25/50/75/100%.
|
||||
const RINGS = [0.25, 0.5, 0.75, 1];
|
||||
|
||||
function ringPath(frac: number): string {
|
||||
const pts = AXIS_ORDER.map(({ angle }) => pointAt(frac, angle));
|
||||
return (
|
||||
pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p[0].toFixed(2)},${p[1].toFixed(2)}`).join(' ') + ' Z'
|
||||
);
|
||||
}
|
||||
|
||||
// Mount-time grow-from-center animation. We scale the values from 0 to 1
|
||||
// over ~600ms with an easeOutCubic curve so the polygon blossoms instead of
|
||||
// popping in. Reactive on prop change would also be nice but this matches
|
||||
// the brief ("animated fill-in on mount").
|
||||
let animProgress = $state(0);
|
||||
|
||||
onMount(() => {
|
||||
const duration = 600;
|
||||
const start = performance.now();
|
||||
let raf = 0;
|
||||
const tick = (now: number) => {
|
||||
const t = Math.min(1, (now - start) / duration);
|
||||
// easeOutCubic
|
||||
animProgress = 1 - Math.pow(1 - t, 3);
|
||||
if (t < 1) raf = requestAnimationFrame(tick);
|
||||
};
|
||||
raf = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
});
|
||||
|
||||
let polygonPath = $derived.by(() => {
|
||||
const scale = animProgress;
|
||||
const pts = AXIS_ORDER.map(({ key, angle }) => pointAt(values[key] * scale, angle));
|
||||
return (
|
||||
pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p[0].toFixed(2)},${p[1].toFixed(2)}`).join(' ') + ' Z'
|
||||
);
|
||||
});
|
||||
|
||||
// Label positions sit slightly outside the 100% ring.
|
||||
function labelPos(angle: number): { x: number; y: number; anchor: 'start' | 'middle' | 'end' } {
|
||||
const offset = radius + (size === 'lg' ? 18 : 12);
|
||||
const x = cx + Math.cos(angle) * offset;
|
||||
const y = cy + Math.sin(angle) * offset;
|
||||
let anchor: 'start' | 'middle' | 'end' = 'middle';
|
||||
if (Math.abs(Math.cos(angle)) > 0.5) {
|
||||
anchor = Math.cos(angle) > 0 ? 'start' : 'end';
|
||||
}
|
||||
return { x, y, anchor };
|
||||
}
|
||||
</script>
|
||||
|
||||
<svg
|
||||
width={pxSize}
|
||||
height={pxSize}
|
||||
viewBox="0 0 {pxSize} {pxSize}"
|
||||
role="img"
|
||||
aria-label="Importance radar: novelty {(values.novelty * 100).toFixed(0)}%, arousal {(values.arousal * 100).toFixed(0)}%, reward {(values.reward * 100).toFixed(0)}%, attention {(values.attention * 100).toFixed(0)}%"
|
||||
>
|
||||
<!-- Concentric rings (grid) -->
|
||||
{#each RINGS as ring}
|
||||
<path
|
||||
d={ringPath(ring)}
|
||||
fill="none"
|
||||
stroke="var(--color-muted)"
|
||||
stroke-opacity={ring === 1 ? 0.45 : 0.18}
|
||||
stroke-width={ring === 1 ? 1 : 0.75}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
<!-- Spokes -->
|
||||
{#each AXIS_ORDER as axis}
|
||||
{@const [x, y] = pointAt(1, axis.angle)}
|
||||
<line
|
||||
x1={cx}
|
||||
y1={cy}
|
||||
x2={x}
|
||||
y2={y}
|
||||
stroke="var(--color-muted)"
|
||||
stroke-opacity="0.2"
|
||||
stroke-width="0.75"
|
||||
/>
|
||||
{/each}
|
||||
|
||||
<!-- Filled polygon -->
|
||||
<path
|
||||
d={polygonPath}
|
||||
fill="var(--color-synapse-glow)"
|
||||
fill-opacity="0.3"
|
||||
stroke="var(--color-synapse-glow)"
|
||||
stroke-width={size === 'sm' ? 1 : 1.5}
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
|
||||
<!-- Vertex dots at the animated positions, only on md/lg for clarity -->
|
||||
{#if size !== 'sm'}
|
||||
{#each AXIS_ORDER as axis}
|
||||
{@const [px, py] = pointAt(values[axis.key] * animProgress, axis.angle)}
|
||||
<circle cx={px} cy={py} r={size === 'lg' ? 3 : 2.25} fill="var(--color-synapse-glow)" />
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<!-- Axis labels (name + value) -->
|
||||
{#if showLabels}
|
||||
{#each AXIS_ORDER as axis}
|
||||
{@const pos = labelPos(axis.angle)}
|
||||
<text
|
||||
x={pos.x}
|
||||
y={pos.y}
|
||||
text-anchor={pos.anchor}
|
||||
dominant-baseline="middle"
|
||||
fill="var(--color-bright)"
|
||||
font-size={size === 'lg' ? 12 : 10}
|
||||
font-family="var(--font-mono)"
|
||||
font-weight="600"
|
||||
>
|
||||
{(values[axis.key] * 100).toFixed(0)}%
|
||||
</text>
|
||||
<text
|
||||
x={pos.x}
|
||||
y={pos.y + (size === 'lg' ? 14 : 11)}
|
||||
text-anchor={pos.anchor}
|
||||
dominant-baseline="middle"
|
||||
fill="var(--color-dim)"
|
||||
font-size={size === 'lg' ? 10 : 8.5}
|
||||
font-family="var(--font-mono)"
|
||||
>
|
||||
{AXIS_LABELS[axis.key]}
|
||||
</text>
|
||||
{/each}
|
||||
{/if}
|
||||
</svg>
|
||||
253
apps/dashboard/src/lib/components/InsightToast.svelte
Normal file
253
apps/dashboard/src/lib/components/InsightToast.svelte
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
<!--
|
||||
InsightToast — v2.2 Pulse
|
||||
Renders the toast queue as a floating overlay. Desktop: bottom-right
|
||||
stack. Mobile: top-center stack. Each toast has a colored left border
|
||||
matching its event type, a progress bar showing dwell, and click-to-
|
||||
dismiss. This is the "brain coming alive" surface — when Vestige dreams,
|
||||
consolidates, forgets, or discovers a new bridge, you see it happen.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { toasts, type Toast } from '$stores/toast';
|
||||
import type { VestigeEventType } from '$types';
|
||||
|
||||
const ICONS: Partial<Record<VestigeEventType, string>> = {
|
||||
DreamCompleted: '✦',
|
||||
ConsolidationCompleted: '◉',
|
||||
ConnectionDiscovered: '⟷',
|
||||
MemoryPromoted: '↑',
|
||||
MemoryDemoted: '↓',
|
||||
MemorySuppressed: '◬',
|
||||
MemoryUnsuppressed: '◉',
|
||||
Rac1CascadeSwept: '✺',
|
||||
MemoryDeleted: '✕',
|
||||
};
|
||||
|
||||
function iconFor(type: VestigeEventType): string {
|
||||
return ICONS[type] ?? '◆';
|
||||
}
|
||||
|
||||
function handleClick(t: Toast) {
|
||||
toasts.dismiss(t.id);
|
||||
}
|
||||
|
||||
function handleKey(e: KeyboardEvent, t: Toast) {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
toasts.dismiss(t.id);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="toast-layer"
|
||||
aria-live="polite"
|
||||
aria-atomic="false"
|
||||
>
|
||||
{#each $toasts as t (t.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="toast-item"
|
||||
aria-label="{t.title}: {t.body}. Click to dismiss."
|
||||
onclick={() => handleClick(t)}
|
||||
onkeydown={(e) => handleKey(e, t)}
|
||||
onmouseenter={() => toasts.pauseDwell(t.id, t.dwellMs)}
|
||||
onmouseleave={() => toasts.resumeDwell(t.id)}
|
||||
onfocus={() => toasts.pauseDwell(t.id, t.dwellMs)}
|
||||
onblur={() => toasts.resumeDwell(t.id)}
|
||||
style="--toast-color: {t.color}; --toast-dwell: {t.dwellMs}ms;"
|
||||
>
|
||||
<div class="toast-accent" aria-hidden="true"></div>
|
||||
<div class="toast-body">
|
||||
<div class="toast-head">
|
||||
<span class="toast-icon" aria-hidden="true">{iconFor(t.type)}</span>
|
||||
<span class="toast-title">{t.title}</span>
|
||||
</div>
|
||||
<div class="toast-sub">{t.body}</div>
|
||||
</div>
|
||||
<div class="toast-progress" aria-hidden="true">
|
||||
<div class="toast-progress-fill"></div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.toast-layer {
|
||||
position: fixed;
|
||||
z-index: 60;
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
right: 1.25rem;
|
||||
bottom: 1.25rem;
|
||||
max-width: 22rem;
|
||||
width: calc(100vw - 2.5rem);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.toast-layer {
|
||||
right: 0.75rem;
|
||||
left: 0.75rem;
|
||||
bottom: auto;
|
||||
top: 0.75rem;
|
||||
max-width: none;
|
||||
width: auto;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
.toast-item {
|
||||
pointer-events: auto;
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: stretch;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
background: rgba(12, 14, 22, 0.72);
|
||||
backdrop-filter: blur(14px) saturate(160%);
|
||||
-webkit-backdrop-filter: blur(14px) saturate(160%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 0.75rem;
|
||||
padding: 0.75rem 0.9rem 0.75rem 0.5rem;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
0 10px 40px -12px rgba(0, 0, 0, 0.8),
|
||||
0 0 22px -6px var(--toast-color);
|
||||
cursor: pointer;
|
||||
animation: toast-in 0.32s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
transform-origin: right center;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.toast-item:hover {
|
||||
transform: translateY(-1px) scale(1.015);
|
||||
box-shadow:
|
||||
0 14px 48px -12px rgba(0, 0, 0, 0.85),
|
||||
0 0 32px -4px var(--toast-color);
|
||||
}
|
||||
|
||||
.toast-item:focus-visible {
|
||||
outline: 1px solid var(--toast-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.toast-accent {
|
||||
width: 3px;
|
||||
border-radius: 2px;
|
||||
background: var(--toast-color);
|
||||
box-shadow: 0 0 10px var(--toast-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toast-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.toast-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.toast-icon {
|
||||
color: var(--toast-color);
|
||||
font-size: 0.95rem;
|
||||
text-shadow: 0 0 8px var(--toast-color);
|
||||
line-height: 1;
|
||||
width: 1rem;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.toast-title {
|
||||
color: #F5F5FA;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.toast-sub {
|
||||
color: #B0B6C4;
|
||||
font-size: 0.74rem;
|
||||
line-height: 1.35;
|
||||
padding-left: 1.5rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toast-progress {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
height: 2px;
|
||||
width: 100%;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.toast-progress-fill {
|
||||
height: 100%;
|
||||
background: var(--toast-color);
|
||||
opacity: 0.55;
|
||||
transform-origin: left center;
|
||||
animation: toast-progress var(--toast-dwell) linear forwards;
|
||||
}
|
||||
|
||||
/* Hover Panic — freeze auto-dismiss while the user is engaged.
|
||||
* Pairs with toasts.pauseDwell/resumeDwell on the JS side. */
|
||||
.toast-item:hover .toast-progress-fill,
|
||||
.toast-item:focus-visible .toast-progress-fill {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
|
||||
@keyframes toast-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(24px) scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.toast-item {
|
||||
transform-origin: top center;
|
||||
animation: toast-in-mobile 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes toast-in-mobile {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-12px) scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes toast-progress {
|
||||
from { transform: scaleX(1); }
|
||||
to { transform: scaleX(0); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.toast-item {
|
||||
animation: none;
|
||||
}
|
||||
.toast-progress-fill {
|
||||
animation: none;
|
||||
transform: scaleX(0.5);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
185
apps/dashboard/src/lib/components/MemoryAuditTrail.svelte
Normal file
185
apps/dashboard/src/lib/components/MemoryAuditTrail.svelte
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import {
|
||||
META,
|
||||
generateMockAuditTrail,
|
||||
relativeTime,
|
||||
formatRetentionDelta,
|
||||
splitVisible,
|
||||
type AuditEvent
|
||||
} from './audit-trail-helpers';
|
||||
|
||||
/**
|
||||
* MemoryAuditTrail — per-memory "Sources" panel.
|
||||
*
|
||||
* Renders a vertical bioluminescent timeline of every event that has touched
|
||||
* a memory: creation, accesses, promotions/demotions, edits, suppressions,
|
||||
* dream-cycle activations, and reconsolidations (5-min labile window edits).
|
||||
*
|
||||
* Backend `/api/changelog?memory_id=X` does NOT yet exist. A typed mock
|
||||
* fetcher lives in `audit-trail-helpers.ts` and will be swapped for
|
||||
* `api.memoryChangelog(id)` once the backend route ships.
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
memoryId: string;
|
||||
}
|
||||
|
||||
let { memoryId }: Props = $props();
|
||||
|
||||
let events: AuditEvent[] = $state([]);
|
||||
let loading = $state(true);
|
||||
let errored = $state(false);
|
||||
let showAllOlder = $state(false);
|
||||
|
||||
// TODO: swap for api.memoryChangelog(id) when backend ships
|
||||
async function fetchAuditTrail(id: string): Promise<AuditEvent[]> {
|
||||
return generateMockAuditTrail(id, Date.now());
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
if (!memoryId) {
|
||||
events = [];
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
events = await fetchAuditTrail(memoryId);
|
||||
} catch {
|
||||
events = [];
|
||||
errored = true;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
|
||||
const split = $derived(splitVisible(events, showAllOlder));
|
||||
const visibleEvents = $derived(split.visible);
|
||||
const hiddenCount = $derived(split.hiddenCount);
|
||||
</script>
|
||||
|
||||
<div class="audit-trail space-y-3" aria-label="Audit trail">
|
||||
{#if loading}
|
||||
<div class="space-y-2">
|
||||
{#each Array(5) as _}
|
||||
<div class="h-10 glass-subtle rounded-lg animate-pulse"></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if errored}
|
||||
<p class="text-xs text-decay italic">Audit trail failed to load.</p>
|
||||
{:else if !memoryId}
|
||||
<p class="text-xs text-muted italic">No memory selected.</p>
|
||||
{:else if events.length === 0}
|
||||
<p class="text-xs text-muted italic">No audit events recorded yet.</p>
|
||||
{:else}
|
||||
<ol class="relative pl-6 border-l border-synapse/15 space-y-3">
|
||||
{#each visibleEvents as ev, i (ev.timestamp + i)}
|
||||
{@const m = META[ev.action]}
|
||||
{@const delta = formatRetentionDelta(ev.old_value, ev.new_value)}
|
||||
<li class="relative" style="animation-delay: {i * 40}ms;">
|
||||
<!-- Marker -->
|
||||
<span
|
||||
class="marker absolute -left-[29px] top-0.5 w-4 h-4 flex items-center justify-center rounded-full"
|
||||
style="background: rgba(10,10,26,0.9); box-shadow: 0 0 10px {m.color}88; border: 1px solid {m.color};"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{#if m.kind === 'dot'}
|
||||
<span class="w-1.5 h-1.5 rounded-full" style="background: {m.color};"></span>
|
||||
{:else if m.kind === 'ring'}
|
||||
<span
|
||||
class="w-2 h-2 rounded-full border"
|
||||
style="border-color: {m.color}; background: transparent;"
|
||||
></span>
|
||||
{:else if m.kind === 'arrow-up'}
|
||||
<svg viewBox="0 0 12 12" class="w-2.5 h-2.5" fill="none" stroke={m.color} stroke-width="2">
|
||||
<path d="M6 10V2M3 5l3-3 3 3" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
{:else if m.kind === 'arrow-down'}
|
||||
<svg viewBox="0 0 12 12" class="w-2.5 h-2.5" fill="none" stroke={m.color} stroke-width="2">
|
||||
<path d="M6 2v8M3 7l3 3 3-3" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
{:else if m.kind === 'pencil'}
|
||||
<svg viewBox="0 0 12 12" class="w-2.5 h-2.5" fill="none" stroke={m.color} stroke-width="1.5">
|
||||
<path d="M8.5 1.5l2 2L4 10l-3 1 1-3 6.5-6.5z" stroke-linejoin="round" />
|
||||
</svg>
|
||||
{:else if m.kind === 'x'}
|
||||
<svg viewBox="0 0 12 12" class="w-2.5 h-2.5" fill="none" stroke={m.color} stroke-width="2">
|
||||
<path d="M2 2l8 8M10 2l-8 8" stroke-linecap="round" />
|
||||
</svg>
|
||||
{:else if m.kind === 'star'}
|
||||
<svg viewBox="0 0 12 12" class="w-2.5 h-2.5" fill={m.color} stroke="none">
|
||||
<path d="M6 0.5l1.4 3.3 3.6.3-2.7 2.4.8 3.5L6 8.2l-3.1 1.8.8-3.5L1 4.1l3.6-.3L6 .5z" />
|
||||
</svg>
|
||||
{:else if m.kind === 'circle-arrow'}
|
||||
<svg viewBox="0 0 12 12" class="w-2.5 h-2.5" fill="none" stroke={m.color} stroke-width="1.5">
|
||||
<path d="M10 6a4 4 0 1 1-1.2-2.8" stroke-linecap="round" />
|
||||
<path d="M10 1.5V4H7.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
<!-- Event content -->
|
||||
<div class="glass-subtle rounded-lg px-3 py-2 space-y-1">
|
||||
<div class="flex items-center justify-between gap-2 flex-wrap">
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<span class="font-semibold" style="color: {m.color};">{m.label}</span>
|
||||
{#if ev.triggered_by}
|
||||
<span class="text-muted font-mono text-[10px]">{ev.triggered_by}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="text-[10px] text-muted font-mono" title={new Date(ev.timestamp).toLocaleString()}>
|
||||
{relativeTime(ev.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
{#if delta}
|
||||
<div class="text-[11px] text-dim font-mono">
|
||||
retention {delta}
|
||||
</div>
|
||||
{/if}
|
||||
{#if ev.reason}
|
||||
<div class="text-[11px] text-dim italic">{ev.reason}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
|
||||
{#if hiddenCount > 0}
|
||||
<button
|
||||
type="button"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
showAllOlder = !showAllOlder;
|
||||
}}
|
||||
class="text-xs text-synapse-glow hover:text-bright transition-colors underline-offset-4 hover:underline"
|
||||
>
|
||||
{showAllOlder ? 'Hide older events' : `Show ${hiddenCount} older event${hiddenCount === 1 ? '' : 's'}…`}
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.audit-trail :global(ol > li) {
|
||||
animation: event-rise 400ms cubic-bezier(0.22, 0.8, 0.3, 1) backwards;
|
||||
}
|
||||
|
||||
@keyframes event-rise {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateX(6px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
:global(.audit-trail .marker) {
|
||||
transition: transform 200ms ease;
|
||||
}
|
||||
|
||||
:global(.audit-trail li:hover .marker) {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
</style>
|
||||
251
apps/dashboard/src/lib/components/PatternTransferHeatmap.svelte
Normal file
251
apps/dashboard/src/lib/components/PatternTransferHeatmap.svelte
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
<!--
|
||||
PatternTransferHeatmap — CrossProjectLearner visualization.
|
||||
Symmetric N×N project grid. Cell (row=A, col=B) intensity encodes how many
|
||||
patterns were learned in project A and reused in project B. Diagonal =
|
||||
self-transfer count (project reusing its own patterns).
|
||||
|
||||
Color scale: muted (no transfers) → synapse glow → dream glow for high
|
||||
transfer counts. Cells are clickable (filters sidebar to A → B pairs) and
|
||||
hoverable (tooltip with count + top 3 pattern names).
|
||||
|
||||
Responsive: the grid collapses to a vertical list of "A → B : count" rows
|
||||
on small viewports so the matrix is still scannable on mobile.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import {
|
||||
buildTransferMatrix,
|
||||
flattenNonZero,
|
||||
matrixMaxCount,
|
||||
shortProjectName,
|
||||
type PatternCategory,
|
||||
type TransferPatternLike,
|
||||
} from './patterns-helpers';
|
||||
|
||||
interface Pattern extends TransferPatternLike {
|
||||
category: PatternCategory;
|
||||
last_used: string;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
projects: string[];
|
||||
patterns: Pattern[];
|
||||
selectedCell: { from: string; to: string } | null;
|
||||
onCellClick: (from: string, to: string) => void;
|
||||
}
|
||||
|
||||
let { projects, patterns, selectedCell, onCellClick }: Props = $props();
|
||||
|
||||
// Matrix build, max-count, and non-zero flattening all live in
|
||||
// `patterns-helpers.ts` so they can be unit tested in the Vitest node env.
|
||||
const matrix = $derived(buildTransferMatrix(projects, patterns));
|
||||
const maxCount = $derived(matrixMaxCount(projects, matrix) || 1);
|
||||
|
||||
// Hover tooltip state
|
||||
let hoveredCell = $state<{ from: string; to: string; x: number; y: number } | null>(null);
|
||||
|
||||
function cellStyle(count: number): string {
|
||||
if (count === 0) {
|
||||
return 'background: rgba(255,255,255,0.02); border-color: rgba(99,102,241,0.05);';
|
||||
}
|
||||
const intensity = count / maxCount; // 0..1
|
||||
// Two-stop gradient: synapse (indigo) for low-mid → dream (purple) for high.
|
||||
// Alpha ramps 0.10 → 0.80 so even low-count cells read clearly.
|
||||
const alpha = 0.1 + intensity * 0.7;
|
||||
if (intensity < 0.5) {
|
||||
// Synapse-dominant
|
||||
return `background: rgba(99, 102, 241, ${alpha.toFixed(3)}); border-color: rgba(129, 140, 248, ${(alpha * 0.6).toFixed(3)}); box-shadow: 0 0 ${(intensity * 14).toFixed(1)}px rgba(129, 140, 248, ${(intensity * 0.45).toFixed(3)});`;
|
||||
} else {
|
||||
// Dream-dominant for the hottest cells
|
||||
const dreamIntensity = (intensity - 0.5) * 2; // 0..1 over upper half
|
||||
const r = Math.round(99 + (168 - 99) * dreamIntensity);
|
||||
const g = Math.round(102 + (85 - 102) * dreamIntensity);
|
||||
const b = Math.round(241 + (247 - 241) * dreamIntensity);
|
||||
return `background: rgba(${r}, ${g}, ${b}, ${alpha.toFixed(3)}); border-color: rgba(192, 132, 252, ${(alpha * 0.7).toFixed(3)}); box-shadow: 0 0 ${(6 + intensity * 18).toFixed(1)}px rgba(192, 132, 252, ${(intensity * 0.55).toFixed(3)});`;
|
||||
}
|
||||
}
|
||||
|
||||
function cellTextClass(count: number): string {
|
||||
if (count === 0) return 'text-muted';
|
||||
const intensity = count / maxCount;
|
||||
if (intensity >= 0.5) return 'text-bright font-semibold';
|
||||
if (intensity >= 0.2) return 'text-text';
|
||||
return 'text-dim';
|
||||
}
|
||||
|
||||
function handleCellHover(ev: MouseEvent, from: string, to: string) {
|
||||
const target = ev.currentTarget as HTMLElement;
|
||||
const rect = target.getBoundingClientRect();
|
||||
hoveredCell = {
|
||||
from,
|
||||
to,
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.top
|
||||
};
|
||||
}
|
||||
|
||||
function handleCellLeave() {
|
||||
hoveredCell = null;
|
||||
}
|
||||
|
||||
// Axis labels are diagonal; trim long names via the shared helper.
|
||||
const shortProject = shortProjectName;
|
||||
|
||||
function isSelected(from: string, to: string): boolean {
|
||||
return selectedCell !== null && selectedCell.from === from && selectedCell.to === to;
|
||||
}
|
||||
|
||||
// Flattened list for the mobile fallback: only non-zero cells, sorted desc.
|
||||
const mobileList = $derived(flattenNonZero(projects, matrix));
|
||||
</script>
|
||||
|
||||
<div class="glass-panel relative rounded-2xl p-5">
|
||||
<!-- Desktop / tablet: grid heatmap -->
|
||||
<div class="hidden md:block">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div class="text-xs text-dim">
|
||||
Rows = origin project · Columns = destination project
|
||||
</div>
|
||||
<!-- Legend gradient -->
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-[10px] text-muted">0</span>
|
||||
<div
|
||||
class="h-2 w-32 rounded-full"
|
||||
style="background: linear-gradient(to right, rgba(255,255,255,0.05), rgba(99,102,241,0.5), rgba(168,85,247,0.85));"
|
||||
></div>
|
||||
<span class="text-[10px] text-muted">{maxCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full border-separate" style="border-spacing: 4px;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="w-24"></th>
|
||||
{#each projects as proj (proj)}
|
||||
<th
|
||||
class="h-20 min-w-16 max-w-20 align-bottom"
|
||||
title={proj}
|
||||
>
|
||||
<div
|
||||
class="mx-auto flex h-20 w-6 items-end justify-center"
|
||||
style="writing-mode: vertical-rl; transform: rotate(180deg);"
|
||||
>
|
||||
<span class="text-[11px] text-dim font-medium tracking-wide">
|
||||
{shortProject(proj)}
|
||||
</span>
|
||||
</div>
|
||||
</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each projects as from (from)}
|
||||
<tr>
|
||||
<td class="w-24 pr-2 text-right text-[11px] text-dim" title={from}>
|
||||
{shortProject(from)}
|
||||
</td>
|
||||
{#each projects as to (to)}
|
||||
{@const cell = matrix[from][to]}
|
||||
{@const isDiag = from === to}
|
||||
<td class="p-0">
|
||||
<button
|
||||
type="button"
|
||||
class="group relative h-10 w-full min-w-12 rounded-md border transition-all duration-200 hover:scale-110 hover:z-10 focus:outline-none focus:ring-2 focus:ring-synapse-glow"
|
||||
style="{cellStyle(cell.count)} {isSelected(from, to)
|
||||
? 'outline: 2px solid var(--color-dream-glow); outline-offset: 1px;'
|
||||
: ''} {isDiag && cell.count > 0
|
||||
? 'border-style: dashed;'
|
||||
: ''}"
|
||||
onclick={() => onCellClick(from, to)}
|
||||
onmouseenter={(e) => handleCellHover(e, from, to)}
|
||||
onmouseleave={handleCellLeave}
|
||||
aria-label="{cell.count} patterns from {from} to {to}"
|
||||
>
|
||||
<span class="text-[11px] {cellTextClass(cell.count)}">
|
||||
{cell.count || ''}
|
||||
</span>
|
||||
</button>
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Hover tooltip -->
|
||||
{#if hoveredCell}
|
||||
{@const cell = matrix[hoveredCell.from][hoveredCell.to]}
|
||||
<div
|
||||
class="glass-panel pointer-events-none fixed z-50 max-w-xs rounded-lg p-3 text-xs shadow-2xl"
|
||||
style="left: {hoveredCell.x}px; top: {hoveredCell.y - 12}px; transform: translate(-50%, -100%);"
|
||||
>
|
||||
<div class="mb-1 flex items-center gap-2">
|
||||
<span class="font-mono text-dim">{shortProject(hoveredCell.from)}</span>
|
||||
<span class="text-synapse-glow">→</span>
|
||||
<span class="font-mono text-bright">{shortProject(hoveredCell.to)}</span>
|
||||
</div>
|
||||
<div class="mb-2 text-lg font-semibold text-bright">
|
||||
{cell.count}
|
||||
<span class="text-xs font-normal text-dim">
|
||||
{cell.count === 1 ? 'pattern' : 'patterns'} transferred
|
||||
</span>
|
||||
</div>
|
||||
{#if cell.topNames.length > 0}
|
||||
<div class="space-y-1 border-t border-synapse/10 pt-2">
|
||||
<div class="text-[10px] uppercase tracking-wider text-muted">Top patterns</div>
|
||||
{#each cell.topNames as name}
|
||||
<div class="truncate text-text">· {name}</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-muted">No transfers recorded</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Mobile: vertical list of non-zero transfers -->
|
||||
<div class="space-y-2 md:hidden">
|
||||
<div class="mb-2 text-xs text-dim">
|
||||
{mobileList.length} transfer pair{mobileList.length === 1 ? '' : 's'} · tap to filter
|
||||
</div>
|
||||
{#if mobileList.length === 0}
|
||||
<div class="rounded-lg bg-white/[0.02] p-4 text-center text-xs text-muted">
|
||||
No cross-project transfers recorded yet.
|
||||
</div>
|
||||
{:else}
|
||||
{#each mobileList as row (row.from + '->' + row.to)}
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between rounded-lg border border-synapse/10 bg-white/[0.02] p-3 transition hover:border-synapse/30 hover:bg-white/[0.04] {isSelected(
|
||||
row.from,
|
||||
row.to
|
||||
)
|
||||
? 'ring-1 ring-dream-glow'
|
||||
: ''}"
|
||||
onclick={() => onCellClick(row.from, row.to)}
|
||||
>
|
||||
<div class="flex min-w-0 flex-col items-start gap-0.5">
|
||||
<div class="flex items-center gap-1.5 text-xs">
|
||||
<span class="font-mono text-dim">{shortProject(row.from)}</span>
|
||||
<span class="text-synapse-glow">→</span>
|
||||
<span class="font-mono text-bright">{shortProject(row.to)}</span>
|
||||
</div>
|
||||
{#if row.topNames.length > 0}
|
||||
<div class="truncate text-[11px] text-muted">
|
||||
{row.topNames.join(' · ')}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<span
|
||||
class="ml-3 flex-shrink-0 rounded-full bg-synapse/15 px-2 py-0.5 text-xs font-semibold text-synapse-glow"
|
||||
>
|
||||
{row.count}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
259
apps/dashboard/src/lib/components/ReasoningChain.svelte
Normal file
259
apps/dashboard/src/lib/components/ReasoningChain.svelte
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
<script lang="ts">
|
||||
interface StageResult {
|
||||
label?: string;
|
||||
value?: string | number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
intent?: string;
|
||||
memoriesAnalyzed?: number;
|
||||
evidenceCount?: number;
|
||||
contradictionCount?: number;
|
||||
supersededCount?: number;
|
||||
running?: boolean; // when true, run the sequential light-up animation
|
||||
// Optional per-stage hints (one-liners) — if provided, overrides defaults
|
||||
stageHints?: Partial<Record<StageKey, string>>;
|
||||
}
|
||||
|
||||
type StageKey =
|
||||
| 'broad'
|
||||
| 'spreading'
|
||||
| 'fsrs'
|
||||
| 'intent'
|
||||
| 'supersession'
|
||||
| 'contradiction'
|
||||
| 'relation'
|
||||
| 'template';
|
||||
|
||||
let {
|
||||
intent = 'Synthesis',
|
||||
memoriesAnalyzed = 0,
|
||||
evidenceCount = 0,
|
||||
contradictionCount = 0,
|
||||
supersededCount = 0,
|
||||
running = false,
|
||||
stageHints = {}
|
||||
}: Props = $props();
|
||||
|
||||
const STAGES: { key: StageKey; icon: string; label: string; base: string }[] = [
|
||||
{
|
||||
key: 'broad',
|
||||
icon: '◎',
|
||||
label: 'Broad Retrieval',
|
||||
base: 'Hybrid BM25 + semantic (3x overfetch) then cross-encoder rerank'
|
||||
},
|
||||
{
|
||||
key: 'spreading',
|
||||
icon: '⟿',
|
||||
label: 'Spreading Activation',
|
||||
base: 'Collins & Loftus — expand via graph edges to surface what search missed'
|
||||
},
|
||||
{
|
||||
key: 'fsrs',
|
||||
icon: '▲',
|
||||
label: 'FSRS Trust Scoring',
|
||||
base: 'retention × stability × reps ÷ lapses — which memories have earned trust'
|
||||
},
|
||||
{
|
||||
key: 'intent',
|
||||
icon: '◆',
|
||||
label: 'Intent Classification',
|
||||
base: 'FactCheck / Timeline / RootCause / Comparison / Synthesis'
|
||||
},
|
||||
{
|
||||
key: 'supersession',
|
||||
icon: '↗',
|
||||
label: 'Temporal Supersession',
|
||||
base: 'Newer high-trust memories replace older ones on the same fact'
|
||||
},
|
||||
{
|
||||
key: 'contradiction',
|
||||
icon: '⚡',
|
||||
label: 'Contradiction Analysis',
|
||||
base: 'Only flag conflicts between memories where BOTH have trust > 0.3'
|
||||
},
|
||||
{
|
||||
key: 'relation',
|
||||
icon: '⬡',
|
||||
label: 'Relation Assessment',
|
||||
base: 'Per pair: Supports / Contradicts / Supersedes / Irrelevant'
|
||||
},
|
||||
{
|
||||
key: 'template',
|
||||
icon: '❖',
|
||||
label: 'Template Reasoning',
|
||||
base: 'Build the natural-language reasoning chain you validate'
|
||||
}
|
||||
];
|
||||
|
||||
// Dynamic one-liners reflecting the actual response — fall back to base
|
||||
const computed: Partial<Record<StageKey, string>> = $derived({
|
||||
broad: memoriesAnalyzed ? `Analyzed ${memoriesAnalyzed} memories · ${evidenceCount} survived ranking` : undefined,
|
||||
intent: intent ? `Classified as ${intent}` : undefined,
|
||||
supersession: supersededCount
|
||||
? `${supersededCount} outdated memor${supersededCount === 1 ? 'y' : 'ies'} superseded`
|
||||
: undefined,
|
||||
contradiction: contradictionCount
|
||||
? `${contradictionCount} real conflict${contradictionCount === 1 ? '' : 's'} between trusted memories`
|
||||
: 'No conflicts between trusted memories'
|
||||
});
|
||||
|
||||
function hintFor(key: StageKey, base: string): string {
|
||||
return stageHints[key] ?? computed[key] ?? base;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="reasoning-chain space-y-2" class:running>
|
||||
{#each STAGES as stage, i (stage.key)}
|
||||
<div
|
||||
class="stage glass-subtle rounded-xl p-3 flex items-start gap-3 relative"
|
||||
style="animation-delay: {i * 140}ms;"
|
||||
>
|
||||
<!-- Connector line down to next stage -->
|
||||
{#if i < STAGES.length - 1}
|
||||
<div class="connector" style="animation-delay: {i * 140 + 120}ms;"></div>
|
||||
{/if}
|
||||
|
||||
<!-- Stage index + icon -->
|
||||
<div class="stage-orb flex-shrink-0" style="animation-delay: {i * 140}ms;">
|
||||
<span class="text-xs text-synapse-glow">{stage.icon}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-0.5">
|
||||
<span class="text-[10px] font-mono text-muted">0{i + 1}</span>
|
||||
<span class="text-sm text-bright font-medium">{stage.label}</span>
|
||||
</div>
|
||||
<p class="text-xs text-dim leading-snug">{hintFor(stage.key, stage.base)}</p>
|
||||
</div>
|
||||
|
||||
<span class="stage-pulse" aria-hidden="true"></span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.stage {
|
||||
animation: stage-light 700ms cubic-bezier(0.22, 0.8, 0.3, 1) backwards;
|
||||
position: relative;
|
||||
border-color: rgba(99, 102, 241, 0.08);
|
||||
}
|
||||
|
||||
.stage-orb {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(
|
||||
circle at 30% 30%,
|
||||
rgba(129, 140, 248, 0.25),
|
||||
rgba(99, 102, 241, 0.05)
|
||||
);
|
||||
border: 1px solid rgba(99, 102, 241, 0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
animation: orb-glow 700ms cubic-bezier(0.22, 0.8, 0.3, 1) backwards;
|
||||
}
|
||||
|
||||
.stage-pulse {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(129, 140, 248, 0);
|
||||
pointer-events: none;
|
||||
animation: pulse-ring 700ms cubic-bezier(0.22, 0.8, 0.3, 1) backwards;
|
||||
}
|
||||
|
||||
.connector {
|
||||
position: absolute;
|
||||
left: 22px;
|
||||
top: 100%;
|
||||
width: 1px;
|
||||
height: 8px;
|
||||
background: linear-gradient(180deg, rgba(129, 140, 248, 0.5), rgba(168, 85, 247, 0.15));
|
||||
animation: connector-draw 500ms ease-out backwards;
|
||||
}
|
||||
|
||||
.running .stage {
|
||||
animation: stage-light 700ms cubic-bezier(0.22, 0.8, 0.3, 1) backwards,
|
||||
stage-flicker 2400ms ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes stage-light {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateX(-8px);
|
||||
border-color: rgba(99, 102, 241, 0);
|
||||
}
|
||||
60% {
|
||||
opacity: 1;
|
||||
border-color: rgba(129, 140, 248, 0.35);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
border-color: rgba(99, 102, 241, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes orb-glow {
|
||||
0% {
|
||||
transform: scale(0.6);
|
||||
opacity: 0;
|
||||
box-shadow: 0 0 0 rgba(129, 140, 248, 0);
|
||||
}
|
||||
60% {
|
||||
transform: scale(1.15);
|
||||
opacity: 1;
|
||||
box-shadow: 0 0 24px rgba(129, 140, 248, 0.8);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 0 10px rgba(129, 140, 248, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-ring {
|
||||
0% {
|
||||
transform: scale(0.96);
|
||||
opacity: 0;
|
||||
border-color: rgba(129, 140, 248, 0);
|
||||
}
|
||||
70% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
border-color: rgba(129, 140, 248, 0.4);
|
||||
box-shadow: 0 0 20px rgba(129, 140, 248, 0.25);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.01);
|
||||
opacity: 0;
|
||||
border-color: rgba(129, 140, 248, 0);
|
||||
box-shadow: 0 0 0 rgba(129, 140, 248, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes connector-draw {
|
||||
0% {
|
||||
transform: scaleY(0);
|
||||
transform-origin: top;
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
transform: scaleY(1);
|
||||
transform-origin: top;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes stage-flicker {
|
||||
0%,
|
||||
100% {
|
||||
border-color: rgba(99, 102, 241, 0.08);
|
||||
}
|
||||
50% {
|
||||
border-color: rgba(129, 140, 248, 0.25);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
175
apps/dashboard/src/lib/components/ThemeToggle.svelte
Normal file
175
apps/dashboard/src/lib/components/ThemeToggle.svelte
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
<!--
|
||||
ThemeToggle — closes GitHub issue #11.
|
||||
Small 30px icon button. Click cycles dark → light → auto → dark.
|
||||
Shows the current mode via icon + aria-label + tooltip. Smooth 200ms
|
||||
fade/scale crossfade between icons so the state change feels tactile.
|
||||
|
||||
Theme overrides live in $stores/theme (injected stylesheet approach —
|
||||
app.css is never mutated). The button itself uses existing glass
|
||||
tokens so it drops cleanly into the sidebar/header.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { theme, cycleTheme, type Theme } from '$stores/theme';
|
||||
|
||||
// Cycle order determines the label shown in the tooltip/aria.
|
||||
const LABELS: Record<Theme, string> = {
|
||||
dark: 'Dark',
|
||||
light: 'Light',
|
||||
auto: 'Auto (system)'
|
||||
};
|
||||
|
||||
const NEXT: Record<Theme, Theme> = {
|
||||
dark: 'light',
|
||||
light: 'auto',
|
||||
auto: 'dark'
|
||||
};
|
||||
|
||||
let current = $derived($theme);
|
||||
let nextMode = $derived(NEXT[current]);
|
||||
let ariaLabel = $derived(`Toggle theme: ${LABELS[current]} (click for ${LABELS[nextMode]})`);
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="theme-toggle"
|
||||
aria-label={ariaLabel}
|
||||
title={ariaLabel}
|
||||
onclick={cycleTheme}
|
||||
data-mode={current}
|
||||
>
|
||||
<!-- Three SVG icons stacked, crossfade by opacity. Only the active
|
||||
one is visible; aria-hidden on all since the button label
|
||||
carries the semantics. -->
|
||||
<span class="icon-wrap">
|
||||
<!-- MOON (dark mode) -->
|
||||
<svg
|
||||
class="icon"
|
||||
class:active={current === 'dark'}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.75"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
</svg>
|
||||
|
||||
<!-- SUN (light mode) -->
|
||||
<svg
|
||||
class="icon"
|
||||
class:active={current === 'light'}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.75"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" />
|
||||
</svg>
|
||||
|
||||
<!-- AUTO (half-moon with gear teeth) -->
|
||||
<svg
|
||||
class="icon"
|
||||
class:active={current === 'auto'}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.75"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<!-- Left half filled (dark side), right half outlined (light side) -->
|
||||
<circle cx="12" cy="12" r="8" />
|
||||
<path d="M12 4 A8 8 0 0 0 12 20 Z" fill="currentColor" stroke="none" />
|
||||
<!-- Tiny gear notches to signal 'system / automatic' -->
|
||||
<path d="M12 2v1.5M12 20.5V22M3.5 12H2M22 12h-1.5" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.theme-toggle {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border-radius: 8px;
|
||||
background: rgba(99, 102, 241, 0.06);
|
||||
border: 1px solid rgba(99, 102, 241, 0.14);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 200ms ease,
|
||||
border-color 200ms ease,
|
||||
color 200ms ease,
|
||||
transform 120ms ease;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
background: rgba(99, 102, 241, 0.14);
|
||||
border-color: rgba(99, 102, 241, 0.3);
|
||||
color: var(--color-bright);
|
||||
}
|
||||
|
||||
.theme-toggle:active {
|
||||
transform: scale(0.94);
|
||||
}
|
||||
|
||||
.theme-toggle:focus-visible {
|
||||
outline: 1px solid var(--color-synapse);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.icon-wrap {
|
||||
position: relative;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.icon {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
opacity: 0;
|
||||
transform: scale(0.7) rotate(-30deg);
|
||||
transition:
|
||||
opacity 200ms ease,
|
||||
transform 200ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.icon.active {
|
||||
opacity: 1;
|
||||
transform: scale(1) rotate(0deg);
|
||||
}
|
||||
|
||||
/* Subtle mode-specific accent tint so the button itself reflects
|
||||
* the active mode at a glance. */
|
||||
.theme-toggle[data-mode='dark'] {
|
||||
color: var(--color-synapse-glow, #818cf8);
|
||||
}
|
||||
.theme-toggle[data-mode='light'] {
|
||||
color: var(--color-warning, #f59e0b);
|
||||
}
|
||||
.theme-toggle[data-mode='auto'] {
|
||||
color: var(--color-dream-glow, #c084fc);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.theme-toggle,
|
||||
.icon {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,464 @@
|
|||
/**
|
||||
* Unit tests for Spreading Activation helpers.
|
||||
*
|
||||
* Pure-logic coverage only — the SVG render layer is not exercised here
|
||||
* (no jsdom). The six concerns we test are the ones that actually decide
|
||||
* whether the burst looks right:
|
||||
*
|
||||
* 1. Per-tick decay math (Collins & Loftus 1975, 0.93/frame)
|
||||
* 2. Compound decay after N ticks
|
||||
* 3. Threshold filter (activation < 0.05 → invisible)
|
||||
* 4. Concentric-ring placement around a source (8-per-ring, even angles)
|
||||
* 5. Color mapping (source → synapse-glow, unknown type → fallback)
|
||||
* 6. Staggered edge delay (rank ordering, ring-2 bonus)
|
||||
* 7. Event-feed filter (only NEW ActivationSpread events since lastSeen)
|
||||
*
|
||||
* The test environment is Node (vitest `environment: 'node'`) — the same
|
||||
* harness the graph + dream helper tests use.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
DECAY,
|
||||
FALLBACK_COLOR,
|
||||
MIN_VISIBLE,
|
||||
RING_GAP,
|
||||
RING_1_CAPACITY,
|
||||
SOURCE_COLOR,
|
||||
STAGGER_PER_RANK,
|
||||
STAGGER_RING_2_BONUS,
|
||||
activationColor,
|
||||
applyDecay,
|
||||
compoundDecay,
|
||||
computeRing,
|
||||
edgeStagger,
|
||||
filterNewSpreadEvents,
|
||||
initialActivation,
|
||||
isVisible,
|
||||
layoutNeighbours,
|
||||
ringPositions,
|
||||
ticksUntilInvisible,
|
||||
} from '../activation-helpers';
|
||||
import { NODE_TYPE_COLORS, type VestigeEvent } from '$types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Decay math — single tick
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('applyDecay (Collins & Loftus 1975, 0.93/frame)', () => {
|
||||
it('multiplies activation by 0.93 per tick', () => {
|
||||
expect(applyDecay(1)).toBeCloseTo(0.93, 10);
|
||||
});
|
||||
|
||||
it('matches the documented constant', () => {
|
||||
expect(DECAY).toBe(0.93);
|
||||
});
|
||||
|
||||
it('returns 0 for zero / negative / non-finite input', () => {
|
||||
expect(applyDecay(0)).toBe(0);
|
||||
expect(applyDecay(-0.5)).toBe(0);
|
||||
expect(applyDecay(Number.NaN)).toBe(0);
|
||||
expect(applyDecay(Number.POSITIVE_INFINITY)).toBe(0);
|
||||
});
|
||||
|
||||
it('preserves strict monotonic decrease', () => {
|
||||
let a = 1;
|
||||
let prev = a;
|
||||
for (let i = 0; i < 50; i++) {
|
||||
a = applyDecay(a);
|
||||
if (a === 0) break;
|
||||
expect(a).toBeLessThan(prev);
|
||||
prev = a;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Compound decay — N ticks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('compoundDecay', () => {
|
||||
it('0 ticks returns the input unchanged', () => {
|
||||
expect(compoundDecay(0.8, 0)).toBe(0.8);
|
||||
});
|
||||
|
||||
it('N ticks equals applyDecay called N times', () => {
|
||||
let iterative = 1;
|
||||
for (let i = 0; i < 10; i++) iterative = applyDecay(iterative);
|
||||
expect(compoundDecay(1, 10)).toBeCloseTo(iterative, 10);
|
||||
});
|
||||
|
||||
it('5 ticks from 1.0 lands in the 0.69..0.70 band', () => {
|
||||
// 0.93^5 ≈ 0.6957
|
||||
const result = compoundDecay(1, 5);
|
||||
expect(result).toBeGreaterThan(0.69);
|
||||
expect(result).toBeLessThan(0.7);
|
||||
});
|
||||
|
||||
it('treats negative tick counts as no-op', () => {
|
||||
expect(compoundDecay(0.5, -3)).toBe(0.5);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. Threshold filter — fade/remove below MIN_VISIBLE
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('isVisible / MIN_VISIBLE threshold', () => {
|
||||
it('MIN_VISIBLE is exactly 0.05', () => {
|
||||
expect(MIN_VISIBLE).toBe(0.05);
|
||||
});
|
||||
|
||||
it('returns true at exactly the threshold (inclusive floor)', () => {
|
||||
expect(isVisible(0.05)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false just below the threshold', () => {
|
||||
expect(isVisible(0.0499)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for zero / negative / NaN', () => {
|
||||
expect(isVisible(0)).toBe(false);
|
||||
expect(isVisible(-0.1)).toBe(false);
|
||||
expect(isVisible(Number.NaN)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for typical full-activation source', () => {
|
||||
expect(isVisible(1)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ticksUntilInvisible', () => {
|
||||
it('returns 0 when input is already at/below MIN_VISIBLE', () => {
|
||||
expect(ticksUntilInvisible(MIN_VISIBLE)).toBe(0);
|
||||
expect(ticksUntilInvisible(0.03)).toBe(0);
|
||||
expect(ticksUntilInvisible(0)).toBe(0);
|
||||
});
|
||||
|
||||
it('produces a count that actually crosses the threshold', () => {
|
||||
const n = ticksUntilInvisible(1);
|
||||
expect(n).toBeGreaterThan(0);
|
||||
// After n ticks we should be BELOW the threshold...
|
||||
expect(compoundDecay(1, n)).toBeLessThan(MIN_VISIBLE);
|
||||
// ...but one fewer tick should still be visible.
|
||||
expect(compoundDecay(1, n - 1)).toBeGreaterThanOrEqual(MIN_VISIBLE);
|
||||
});
|
||||
|
||||
it('takes ~42 ticks for a full-strength burst to fade to threshold', () => {
|
||||
// log(0.05) / log(0.93) ≈ 41.27 → ceil → 42
|
||||
expect(ticksUntilInvisible(1)).toBe(42);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. Ring placement
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('computeRing', () => {
|
||||
it('ranks 0..7 land on ring 1', () => {
|
||||
for (let r = 0; r < RING_1_CAPACITY; r++) {
|
||||
expect(computeRing(r)).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('rank 8 and beyond land on ring 2', () => {
|
||||
expect(computeRing(RING_1_CAPACITY)).toBe(2);
|
||||
expect(computeRing(15)).toBe(2);
|
||||
expect(computeRing(99)).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ringPositions (concentric circle layout)', () => {
|
||||
it('returns an empty array for count 0', () => {
|
||||
expect(ringPositions(0, 0, 0, 1)).toEqual([]);
|
||||
});
|
||||
|
||||
it('places 4 nodes on ring 1 at radius RING_GAP, evenly spaced', () => {
|
||||
const pts = ringPositions(0, 0, 4, 1, 0);
|
||||
expect(pts).toHaveLength(4);
|
||||
// First point at angle 0 → (RING_GAP, 0)
|
||||
expect(pts[0].x).toBeCloseTo(RING_GAP, 6);
|
||||
expect(pts[0].y).toBeCloseTo(0, 6);
|
||||
// Every point sits on the circle of the correct radius.
|
||||
for (const p of pts) {
|
||||
const dist = Math.hypot(p.x, p.y);
|
||||
expect(dist).toBeCloseTo(RING_GAP, 6);
|
||||
}
|
||||
});
|
||||
|
||||
it('places ring 2 at 2× RING_GAP from center', () => {
|
||||
const pts = ringPositions(0, 0, 3, 2, 0);
|
||||
for (const p of pts) {
|
||||
expect(Math.hypot(p.x, p.y)).toBeCloseTo(RING_GAP * 2, 6);
|
||||
}
|
||||
});
|
||||
|
||||
it('honours the center (cx, cy)', () => {
|
||||
const pts = ringPositions(500, 280, 2, 1, 0);
|
||||
// With angleOffset=0 and 2 points, the two angles are 0 and π.
|
||||
expect(pts[0].x).toBeCloseTo(500 + RING_GAP, 6);
|
||||
expect(pts[0].y).toBeCloseTo(280, 6);
|
||||
expect(pts[1].x).toBeCloseTo(500 - RING_GAP, 6);
|
||||
expect(pts[1].y).toBeCloseTo(280, 6);
|
||||
});
|
||||
|
||||
it('applies angleOffset to every point', () => {
|
||||
const unrot = ringPositions(0, 0, 3, 1, 0);
|
||||
const rot = ringPositions(0, 0, 3, 1, Math.PI / 2);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
// Rotation preserves distance from center.
|
||||
expect(Math.hypot(rot[i].x, rot[i].y)).toBeCloseTo(
|
||||
Math.hypot(unrot[i].x, unrot[i].y),
|
||||
6,
|
||||
);
|
||||
}
|
||||
// And the first rotated point should now be near (0, RING_GAP) rather
|
||||
// than (RING_GAP, 0).
|
||||
expect(rot[0].x).toBeCloseTo(0, 6);
|
||||
expect(rot[0].y).toBeCloseTo(RING_GAP, 6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('layoutNeighbours (spills overflow to ring 2)', () => {
|
||||
it('returns one point per neighbour', () => {
|
||||
expect(layoutNeighbours(0, 0, 15, 0)).toHaveLength(15);
|
||||
expect(layoutNeighbours(0, 0, 3, 0)).toHaveLength(3);
|
||||
expect(layoutNeighbours(0, 0, 0, 0)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('first 8 neighbours are on ring 1 (radius RING_GAP)', () => {
|
||||
const pts = layoutNeighbours(0, 0, 15, 0);
|
||||
for (let i = 0; i < RING_1_CAPACITY; i++) {
|
||||
expect(Math.hypot(pts[i].x, pts[i].y)).toBeCloseTo(RING_GAP, 6);
|
||||
}
|
||||
});
|
||||
|
||||
it('neighbour 9..N are on ring 2 (radius 2*RING_GAP)', () => {
|
||||
const pts = layoutNeighbours(0, 0, 15, 0);
|
||||
for (let i = RING_1_CAPACITY; i < 15; i++) {
|
||||
expect(Math.hypot(pts[i].x, pts[i].y)).toBeCloseTo(RING_GAP * 2, 6);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('initialActivation', () => {
|
||||
it('rank 0 gets the highest activation', () => {
|
||||
const a0 = initialActivation(0, 10);
|
||||
const a1 = initialActivation(1, 10);
|
||||
expect(a0).toBeGreaterThan(a1);
|
||||
});
|
||||
|
||||
it('ring-2 ranks get a 0.75 ring penalty', () => {
|
||||
// Rank 7 (last of ring 1) vs rank 8 (first of ring 2) — the jump in
|
||||
// activation between them should include the 0.75 ring factor.
|
||||
const ring1Last = initialActivation(7, 16);
|
||||
const ring2First = initialActivation(8, 16);
|
||||
expect(ring2First).toBeLessThan(ring1Last * 0.78);
|
||||
});
|
||||
|
||||
it('returns values in (0, 1]', () => {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const a = initialActivation(i, 20);
|
||||
expect(a).toBeGreaterThan(0);
|
||||
expect(a).toBeLessThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns 0 for invalid inputs', () => {
|
||||
expect(initialActivation(-1, 10)).toBe(0);
|
||||
expect(initialActivation(0, 0)).toBe(0);
|
||||
expect(initialActivation(Number.NaN, 10)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. Color mapping
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('activationColor', () => {
|
||||
it('source nodes always use SOURCE_COLOR (synapse-glow)', () => {
|
||||
expect(activationColor('fact', true)).toBe(SOURCE_COLOR);
|
||||
expect(activationColor('concept', true)).toBe(SOURCE_COLOR);
|
||||
// Even if nodeType is garbage, source overrides.
|
||||
expect(activationColor('garbage-type', true)).toBe(SOURCE_COLOR);
|
||||
});
|
||||
|
||||
it('fact → NODE_TYPE_COLORS.fact (#00A8FF)', () => {
|
||||
expect(activationColor('fact', false)).toBe(NODE_TYPE_COLORS.fact);
|
||||
expect(activationColor('fact', false)).toBe('#00A8FF');
|
||||
});
|
||||
|
||||
it('every known node type resolves to its palette entry', () => {
|
||||
for (const type of Object.keys(NODE_TYPE_COLORS)) {
|
||||
expect(activationColor(type, false)).toBe(NODE_TYPE_COLORS[type]);
|
||||
}
|
||||
});
|
||||
|
||||
it('unknown node type falls back to FALLBACK_COLOR (soft steel)', () => {
|
||||
expect(activationColor('not-a-real-type', false)).toBe(FALLBACK_COLOR);
|
||||
expect(FALLBACK_COLOR).toBe('#8B95A5');
|
||||
});
|
||||
|
||||
it('null/undefined/empty nodeType also falls back', () => {
|
||||
expect(activationColor(null, false)).toBe(FALLBACK_COLOR);
|
||||
expect(activationColor(undefined, false)).toBe(FALLBACK_COLOR);
|
||||
expect(activationColor('', false)).toBe(FALLBACK_COLOR);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. Staggered edge delay
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('edgeStagger', () => {
|
||||
it('rank 0 has zero delay (first edge lights up immediately)', () => {
|
||||
expect(edgeStagger(0)).toBe(0);
|
||||
});
|
||||
|
||||
it('ring-1 edges are STAGGER_PER_RANK apart', () => {
|
||||
expect(edgeStagger(1)).toBe(STAGGER_PER_RANK);
|
||||
expect(edgeStagger(2)).toBe(STAGGER_PER_RANK * 2);
|
||||
expect(edgeStagger(7)).toBe(STAGGER_PER_RANK * 7);
|
||||
});
|
||||
|
||||
it('ring-2 edges add STAGGER_RING_2_BONUS on top of rank×stagger', () => {
|
||||
expect(edgeStagger(8)).toBe(8 * STAGGER_PER_RANK + STAGGER_RING_2_BONUS);
|
||||
expect(edgeStagger(12)).toBe(12 * STAGGER_PER_RANK + STAGGER_RING_2_BONUS);
|
||||
});
|
||||
|
||||
it('monotonically non-decreasing', () => {
|
||||
let prev = -1;
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const s = edgeStagger(i);
|
||||
expect(s).toBeGreaterThanOrEqual(prev);
|
||||
prev = s;
|
||||
}
|
||||
});
|
||||
|
||||
it('produces 15 distinct delays for a typical 15-neighbour burst', () => {
|
||||
const delays = Array.from({ length: 15 }, (_, i) => edgeStagger(i));
|
||||
expect(new Set(delays).size).toBe(15);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 7. Event-feed filter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function spreadEvent(
|
||||
source_id: string,
|
||||
target_ids: string[],
|
||||
): VestigeEvent {
|
||||
return { type: 'ActivationSpread', data: { source_id, target_ids } };
|
||||
}
|
||||
|
||||
describe('filterNewSpreadEvents', () => {
|
||||
it('returns [] on empty feed', () => {
|
||||
expect(filterNewSpreadEvents([], null)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns all ActivationSpread payloads when lastSeen is null', () => {
|
||||
const feed = [
|
||||
spreadEvent('a', ['b', 'c']),
|
||||
spreadEvent('d', ['e']),
|
||||
];
|
||||
const out = filterNewSpreadEvents(feed, null);
|
||||
expect(out).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('returns in oldest-first order (feed itself is newest-first)', () => {
|
||||
const newest = spreadEvent('new', ['n1']);
|
||||
const older = spreadEvent('old', ['o1']);
|
||||
const out = filterNewSpreadEvents([newest, older], null);
|
||||
expect(out[0].source_id).toBe('old');
|
||||
expect(out[1].source_id).toBe('new');
|
||||
});
|
||||
|
||||
it('stops at the lastSeen reference (object identity)', () => {
|
||||
const oldest = spreadEvent('o', ['x']);
|
||||
const middle = spreadEvent('m', ['y']);
|
||||
const newest = spreadEvent('n', ['z']);
|
||||
// Feed is prepended, so order is [newest, middle, oldest]
|
||||
const feed = [newest, middle, oldest];
|
||||
const out = filterNewSpreadEvents(feed, middle);
|
||||
// Only `newest` is fresh — middle and oldest were already processed.
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0].source_id).toBe('n');
|
||||
});
|
||||
|
||||
it('returns [] if lastSeen is already the newest event', () => {
|
||||
const e = spreadEvent('a', ['b']);
|
||||
const out = filterNewSpreadEvents([e], e);
|
||||
expect(out).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores non-ActivationSpread events', () => {
|
||||
const feed: VestigeEvent[] = [
|
||||
{ type: 'MemoryCreated', data: { id: 'x' } },
|
||||
spreadEvent('a', ['b']),
|
||||
{ type: 'Heartbeat', data: {} },
|
||||
];
|
||||
const out = filterNewSpreadEvents(feed, null);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0].source_id).toBe('a');
|
||||
});
|
||||
|
||||
it('skips malformed ActivationSpread events (missing / wrong-type fields)', () => {
|
||||
const feed: VestigeEvent[] = [
|
||||
{ type: 'ActivationSpread', data: {} }, // missing both
|
||||
{ type: 'ActivationSpread', data: { source_id: 'a' } }, // no targets
|
||||
{ type: 'ActivationSpread', data: { target_ids: ['b'] } }, // no source
|
||||
{
|
||||
type: 'ActivationSpread',
|
||||
data: { source_id: 'a', target_ids: 'not-an-array' },
|
||||
},
|
||||
{
|
||||
type: 'ActivationSpread',
|
||||
data: { source_id: 'a', target_ids: [123, null, 'x'] },
|
||||
},
|
||||
];
|
||||
const out = filterNewSpreadEvents(feed, null);
|
||||
// Only the last one survives, with numeric/null targets filtered out.
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0].source_id).toBe('a');
|
||||
expect(out[0].target_ids).toEqual(['x']);
|
||||
});
|
||||
|
||||
it('preserves target array contents faithfully', () => {
|
||||
const feed = [spreadEvent('src', ['t1', 't2', 't3'])];
|
||||
const out = filterNewSpreadEvents(feed, null);
|
||||
expect(out[0].target_ids).toEqual(['t1', 't2', 't3']);
|
||||
});
|
||||
|
||||
it('does not mutate its inputs', () => {
|
||||
const feed = [spreadEvent('a', ['b', 'c'])];
|
||||
const snapshot = JSON.stringify(feed);
|
||||
filterNewSpreadEvents(feed, null);
|
||||
expect(JSON.stringify(feed)).toBe(snapshot);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sanity: exported constants are the values the docstring promises
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('exported constants (contract pinning)', () => {
|
||||
it('RING_1_CAPACITY is 8', () => {
|
||||
expect(RING_1_CAPACITY).toBe(8);
|
||||
});
|
||||
|
||||
it('STAGGER_PER_RANK is 4 frames', () => {
|
||||
expect(STAGGER_PER_RANK).toBe(4);
|
||||
});
|
||||
|
||||
it('STAGGER_RING_2_BONUS is 12 frames', () => {
|
||||
expect(STAGGER_RING_2_BONUS).toBe(12);
|
||||
});
|
||||
|
||||
it('RING_GAP is 140px', () => {
|
||||
expect(RING_GAP).toBe(140);
|
||||
});
|
||||
|
||||
it('SOURCE_COLOR is synapse-glow #818cf8', () => {
|
||||
expect(SOURCE_COLOR).toBe('#818cf8');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,439 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
ACTIVITY_BUCKET_COUNT,
|
||||
ACTIVITY_BUCKET_MS,
|
||||
ACTIVITY_WINDOW_MS,
|
||||
bucketizeActivity,
|
||||
dreamInsightsCount,
|
||||
findRecentDream,
|
||||
formatAgo,
|
||||
hasRecentSuppression,
|
||||
isDreaming,
|
||||
parseEventTimestamp,
|
||||
type EventLike,
|
||||
} from '../awareness-helpers';
|
||||
|
||||
// Fixed "now" — March 1 2026 12:00:00 UTC. All tests are clock-free.
|
||||
const NOW = Date.parse('2026-03-01T12:00:00.000Z');
|
||||
|
||||
function mkEvent(
|
||||
type: string,
|
||||
data: Record<string, unknown> = {},
|
||||
): EventLike {
|
||||
return { type, data };
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// parseEventTimestamp
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
describe('parseEventTimestamp', () => {
|
||||
it('parses ISO-8601 string', () => {
|
||||
const e = mkEvent('Foo', { timestamp: '2026-03-01T12:00:00.000Z' });
|
||||
expect(parseEventTimestamp(e)).toBe(NOW);
|
||||
});
|
||||
|
||||
it('parses numeric ms (> 1e12)', () => {
|
||||
const e = mkEvent('Foo', { timestamp: NOW });
|
||||
expect(parseEventTimestamp(e)).toBe(NOW);
|
||||
});
|
||||
|
||||
it('parses numeric seconds (<= 1e12) by scaling x1000', () => {
|
||||
const secs = Math.floor(NOW / 1000);
|
||||
const e = mkEvent('Foo', { timestamp: secs });
|
||||
// Allow floating precision — must land in same second
|
||||
const result = parseEventTimestamp(e);
|
||||
expect(result).not.toBeNull();
|
||||
expect(Math.abs((result as number) - NOW)).toBeLessThan(1000);
|
||||
});
|
||||
|
||||
it('falls back to `at` field', () => {
|
||||
const e = mkEvent('Foo', { at: '2026-03-01T12:00:00.000Z' });
|
||||
expect(parseEventTimestamp(e)).toBe(NOW);
|
||||
});
|
||||
|
||||
it('falls back to `occurred_at` field', () => {
|
||||
const e = mkEvent('Foo', { occurred_at: '2026-03-01T12:00:00.000Z' });
|
||||
expect(parseEventTimestamp(e)).toBe(NOW);
|
||||
});
|
||||
|
||||
it('prefers `timestamp` over `at` over `occurred_at`', () => {
|
||||
const e = mkEvent('Foo', {
|
||||
timestamp: '2026-03-01T12:00:00.000Z',
|
||||
at: '2020-01-01T00:00:00.000Z',
|
||||
occurred_at: '2019-01-01T00:00:00.000Z',
|
||||
});
|
||||
expect(parseEventTimestamp(e)).toBe(NOW);
|
||||
});
|
||||
|
||||
it('returns null for missing data', () => {
|
||||
expect(parseEventTimestamp({ type: 'Foo' })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for empty data object', () => {
|
||||
expect(parseEventTimestamp(mkEvent('Foo', {}))).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for bad ISO string', () => {
|
||||
expect(parseEventTimestamp(mkEvent('Foo', { timestamp: 'not-a-date' }))).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for non-finite number (NaN)', () => {
|
||||
expect(parseEventTimestamp(mkEvent('Foo', { timestamp: Number.NaN }))).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for non-finite number (Infinity)', () => {
|
||||
expect(parseEventTimestamp(mkEvent('Foo', { timestamp: Number.POSITIVE_INFINITY }))).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for null timestamp', () => {
|
||||
expect(parseEventTimestamp(mkEvent('Foo', { timestamp: null as unknown as string }))).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for non-string non-number timestamp (object)', () => {
|
||||
expect(parseEventTimestamp(mkEvent('Foo', { timestamp: {} as unknown as string }))).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for a boolean timestamp', () => {
|
||||
expect(parseEventTimestamp(mkEvent('Foo', { timestamp: true as unknown as string }))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// bucketizeActivity
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
describe('bucketizeActivity', () => {
|
||||
it('returns 10 buckets of 30s each covering a 5-min window', () => {
|
||||
expect(ACTIVITY_BUCKET_COUNT).toBe(10);
|
||||
expect(ACTIVITY_BUCKET_MS).toBe(30_000);
|
||||
expect(ACTIVITY_WINDOW_MS).toBe(300_000);
|
||||
const result = bucketizeActivity([], NOW);
|
||||
expect(result).toHaveLength(10);
|
||||
expect(result.every((b) => b.count === 0 && b.ratio === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('assigns newest event to the last bucket (index 9)', () => {
|
||||
const e = mkEvent('MemoryCreated', { timestamp: NOW - 100 });
|
||||
const result = bucketizeActivity([e], NOW);
|
||||
expect(result[9].count).toBe(1);
|
||||
expect(result[9].ratio).toBe(1);
|
||||
for (let i = 0; i < 9; i++) expect(result[i].count).toBe(0);
|
||||
});
|
||||
|
||||
it('assigns oldest-edge event to bucket 0', () => {
|
||||
// Exactly 5 min ago → at start boundary → floor((0)/30s) = 0
|
||||
const e = mkEvent('MemoryCreated', { timestamp: NOW - ACTIVITY_WINDOW_MS + 1 });
|
||||
const result = bucketizeActivity([e], NOW);
|
||||
expect(result[0].count).toBe(1);
|
||||
});
|
||||
|
||||
it('drops events older than 5 min (clock skew / pre-history)', () => {
|
||||
const e = mkEvent('MemoryCreated', { timestamp: NOW - ACTIVITY_WINDOW_MS - 1 });
|
||||
const result = bucketizeActivity([e], NOW);
|
||||
expect(result.every((b) => b.count === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('drops future events (negative clock skew)', () => {
|
||||
const e = mkEvent('MemoryCreated', { timestamp: NOW + 5_000 });
|
||||
const result = bucketizeActivity([e], NOW);
|
||||
expect(result.every((b) => b.count === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('drops Heartbeat events as noise', () => {
|
||||
const e = mkEvent('Heartbeat', { timestamp: NOW - 100 });
|
||||
const result = bucketizeActivity([e], NOW);
|
||||
expect(result.every((b) => b.count === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('drops events with unparseable timestamps', () => {
|
||||
const e = mkEvent('MemoryCreated', { timestamp: 'garbage' });
|
||||
const result = bucketizeActivity([e], NOW);
|
||||
expect(result.every((b) => b.count === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('distributes events across buckets and computes correct ratios', () => {
|
||||
const events = [
|
||||
// Bucket 9 (newest 30s): 3 events
|
||||
mkEvent('MemoryCreated', { timestamp: NOW - 5_000 }),
|
||||
mkEvent('MemoryCreated', { timestamp: NOW - 10_000 }),
|
||||
mkEvent('MemoryCreated', { timestamp: NOW - 15_000 }),
|
||||
// Bucket 8: 1 event (31s - 60s ago)
|
||||
mkEvent('MemoryCreated', { timestamp: NOW - 35_000 }),
|
||||
// Bucket 0 (oldest): 1 event (270s - 300s ago)
|
||||
mkEvent('MemoryCreated', { timestamp: NOW - 290_000 }),
|
||||
];
|
||||
const result = bucketizeActivity(events, NOW);
|
||||
expect(result[9].count).toBe(3);
|
||||
expect(result[8].count).toBe(1);
|
||||
expect(result[0].count).toBe(1);
|
||||
expect(result[9].ratio).toBe(1);
|
||||
expect(result[8].ratio).toBeCloseTo(1 / 3, 5);
|
||||
expect(result[0].ratio).toBeCloseTo(1 / 3, 5);
|
||||
});
|
||||
|
||||
it('handles events with numeric ms timestamp', () => {
|
||||
const e = { type: 'MemoryCreated', data: { timestamp: NOW - 10_000 } };
|
||||
const result = bucketizeActivity([e], NOW);
|
||||
expect(result[9].count).toBe(1);
|
||||
});
|
||||
|
||||
it('works with a mixed real-world feed (200 events, some stale)', () => {
|
||||
const events: EventLike[] = [];
|
||||
for (let i = 0; i < 200; i++) {
|
||||
const offset = i * 3_000; // one every 3s, oldest first
|
||||
events.unshift(mkEvent('MemoryCreated', { timestamp: NOW - offset }));
|
||||
}
|
||||
// add 10 Heartbeats mid-stream
|
||||
for (let i = 0; i < 10; i++) {
|
||||
events.push(mkEvent('Heartbeat', { timestamp: NOW - i * 1_000 }));
|
||||
}
|
||||
const result = bucketizeActivity(events, NOW);
|
||||
// 101 events fit in the [now-300s, now] window: offsets 0, 3s, 6s, …, 300s.
|
||||
// Heartbeats excluded. Sum should be exactly 101.
|
||||
const total = result.reduce((s, b) => s + b.count, 0);
|
||||
expect(total).toBe(101);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// findRecentDream
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
describe('findRecentDream', () => {
|
||||
it('returns null on empty feed', () => {
|
||||
expect(findRecentDream([], NOW)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when no DreamCompleted in feed', () => {
|
||||
const feed = [
|
||||
mkEvent('MemoryCreated', { timestamp: NOW - 1000 }),
|
||||
mkEvent('DreamStarted', { timestamp: NOW - 500 }),
|
||||
];
|
||||
expect(findRecentDream(feed, NOW)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the newest DreamCompleted within 24h', () => {
|
||||
const fresh = mkEvent('DreamCompleted', {
|
||||
timestamp: NOW - 60_000,
|
||||
insights_generated: 7,
|
||||
});
|
||||
const stale = mkEvent('DreamCompleted', {
|
||||
timestamp: NOW - 2 * 24 * 60 * 60 * 1000,
|
||||
});
|
||||
// Feed is newest-first
|
||||
const result = findRecentDream([fresh, stale], NOW);
|
||||
expect(result).toBe(fresh);
|
||||
});
|
||||
|
||||
it('returns null when only DreamCompleted is older than 24h', () => {
|
||||
const stale = mkEvent('DreamCompleted', {
|
||||
timestamp: NOW - 25 * 60 * 60 * 1000,
|
||||
});
|
||||
expect(findRecentDream([stale], NOW)).toBeNull();
|
||||
});
|
||||
|
||||
it('exactly 24h ago still counts (inclusive)', () => {
|
||||
const edge = mkEvent('DreamCompleted', {
|
||||
timestamp: NOW - 24 * 60 * 60 * 1000,
|
||||
});
|
||||
expect(findRecentDream([edge], NOW)).toBe(edge);
|
||||
});
|
||||
|
||||
it('stops at first DreamCompleted in newest-first feed', () => {
|
||||
const newest = mkEvent('DreamCompleted', { timestamp: NOW - 1_000 });
|
||||
const older = mkEvent('DreamCompleted', { timestamp: NOW - 60_000 });
|
||||
expect(findRecentDream([newest, older], NOW)).toBe(newest);
|
||||
});
|
||||
|
||||
it('falls back to nowMs for unparseable timestamps (treated as recent)', () => {
|
||||
const e = mkEvent('DreamCompleted', { timestamp: 'bad' });
|
||||
expect(findRecentDream([e], NOW)).toBe(e);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// dreamInsightsCount
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
describe('dreamInsightsCount', () => {
|
||||
it('returns null for null input', () => {
|
||||
expect(dreamInsightsCount(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when missing', () => {
|
||||
expect(dreamInsightsCount(mkEvent('DreamCompleted', {}))).toBeNull();
|
||||
});
|
||||
|
||||
it('reads insights_generated (snake_case)', () => {
|
||||
expect(
|
||||
dreamInsightsCount(mkEvent('DreamCompleted', { insights_generated: 5 })),
|
||||
).toBe(5);
|
||||
});
|
||||
|
||||
it('reads insightsGenerated (camelCase)', () => {
|
||||
expect(
|
||||
dreamInsightsCount(mkEvent('DreamCompleted', { insightsGenerated: 3 })),
|
||||
).toBe(3);
|
||||
});
|
||||
|
||||
it('prefers snake_case when both present', () => {
|
||||
expect(
|
||||
dreamInsightsCount(
|
||||
mkEvent('DreamCompleted', { insights_generated: 7, insightsGenerated: 99 }),
|
||||
),
|
||||
).toBe(7);
|
||||
});
|
||||
|
||||
it('returns null for non-numeric value', () => {
|
||||
expect(
|
||||
dreamInsightsCount(mkEvent('DreamCompleted', { insights_generated: 'seven' as unknown as number })),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// isDreaming
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
describe('isDreaming', () => {
|
||||
it('returns false for empty feed', () => {
|
||||
expect(isDreaming([], NOW)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when no DreamStarted in feed', () => {
|
||||
expect(isDreaming([mkEvent('MemoryCreated', { timestamp: NOW })], NOW)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for DreamStarted in last 5 min with no DreamCompleted', () => {
|
||||
const feed = [mkEvent('DreamStarted', { timestamp: NOW - 60_000 })];
|
||||
expect(isDreaming(feed, NOW)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for DreamStarted older than 5 min with no DreamCompleted', () => {
|
||||
const feed = [mkEvent('DreamStarted', { timestamp: NOW - 6 * 60 * 1000 })];
|
||||
expect(isDreaming(feed, NOW)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when DreamCompleted newer than DreamStarted', () => {
|
||||
// Feed is newest-first: completed, then started
|
||||
const feed = [
|
||||
mkEvent('DreamCompleted', { timestamp: NOW - 30_000 }),
|
||||
mkEvent('DreamStarted', { timestamp: NOW - 60_000 }),
|
||||
];
|
||||
expect(isDreaming(feed, NOW)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when DreamCompleted is OLDER than DreamStarted (new cycle began)', () => {
|
||||
// Newest-first: started is newer, and there's an older completed from a prior cycle
|
||||
const feed = [
|
||||
mkEvent('DreamStarted', { timestamp: NOW - 30_000 }),
|
||||
mkEvent('DreamCompleted', { timestamp: NOW - 10 * 60 * 1000 }),
|
||||
];
|
||||
expect(isDreaming(feed, NOW)).toBe(true);
|
||||
});
|
||||
|
||||
it('boundary: DreamStarted exactly 5 min ago → still dreaming (>= check)', () => {
|
||||
const feed = [mkEvent('DreamStarted', { timestamp: NOW - 5 * 60 * 1000 })];
|
||||
expect(isDreaming(feed, NOW)).toBe(true);
|
||||
});
|
||||
|
||||
it('only considers FIRST DreamStarted / FIRST DreamCompleted (newest-first semantics)', () => {
|
||||
const feed = [
|
||||
mkEvent('DreamStarted', { timestamp: NOW - 10_000 }),
|
||||
mkEvent('DreamCompleted', { timestamp: NOW - 20_000 }), // older — prior cycle
|
||||
mkEvent('DreamStarted', { timestamp: NOW - 30_000 }), // ignored
|
||||
];
|
||||
expect(isDreaming(feed, NOW)).toBe(true);
|
||||
});
|
||||
|
||||
it('unparseable DreamStarted timestamp falls back to nowMs (counts as dreaming)', () => {
|
||||
const feed = [mkEvent('DreamStarted', { timestamp: 'bad' })];
|
||||
expect(isDreaming(feed, NOW)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// hasRecentSuppression
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
describe('hasRecentSuppression', () => {
|
||||
it('returns false for empty feed', () => {
|
||||
expect(hasRecentSuppression([], NOW)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when no MemorySuppressed in feed', () => {
|
||||
const feed = [
|
||||
mkEvent('MemoryCreated', { timestamp: NOW }),
|
||||
mkEvent('DreamStarted', { timestamp: NOW }),
|
||||
];
|
||||
expect(hasRecentSuppression(feed, NOW)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for MemorySuppressed within 10s', () => {
|
||||
const feed = [mkEvent('MemorySuppressed', { timestamp: NOW - 5_000 })];
|
||||
expect(hasRecentSuppression(feed, NOW)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for MemorySuppressed older than 10s', () => {
|
||||
const feed = [mkEvent('MemorySuppressed', { timestamp: NOW - 11_000 })];
|
||||
expect(hasRecentSuppression(feed, NOW)).toBe(false);
|
||||
});
|
||||
|
||||
it('respects custom threshold', () => {
|
||||
const feed = [mkEvent('MemorySuppressed', { timestamp: NOW - 8_000 })];
|
||||
expect(hasRecentSuppression(feed, NOW, 5_000)).toBe(false);
|
||||
expect(hasRecentSuppression(feed, NOW, 10_000)).toBe(true);
|
||||
});
|
||||
|
||||
it('stops at first MemorySuppressed (newest-first short-circuit)', () => {
|
||||
const feed = [
|
||||
mkEvent('MemorySuppressed', { timestamp: NOW - 30_000 }), // first, outside window
|
||||
mkEvent('MemorySuppressed', { timestamp: NOW - 1_000 }), // inside, but never checked
|
||||
];
|
||||
expect(hasRecentSuppression(feed, NOW)).toBe(false);
|
||||
});
|
||||
|
||||
it('boundary: exactly at threshold counts (>= check)', () => {
|
||||
const feed = [mkEvent('MemorySuppressed', { timestamp: NOW - 10_000 })];
|
||||
expect(hasRecentSuppression(feed, NOW, 10_000)).toBe(true);
|
||||
});
|
||||
|
||||
it('unparseable timestamp falls back to nowMs (flash fires)', () => {
|
||||
const feed = [mkEvent('MemorySuppressed', { timestamp: 'bad' })];
|
||||
expect(hasRecentSuppression(feed, NOW)).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores non-MemorySuppressed events before finding one', () => {
|
||||
const feed = [
|
||||
mkEvent('MemoryCreated', { timestamp: NOW }),
|
||||
mkEvent('DreamStarted', { timestamp: NOW }),
|
||||
mkEvent('MemorySuppressed', { timestamp: NOW - 3_000 }),
|
||||
];
|
||||
expect(hasRecentSuppression(feed, NOW)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// formatAgo
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
describe('formatAgo', () => {
|
||||
it('formats seconds', () => {
|
||||
expect(formatAgo(5_000)).toBe('5s ago');
|
||||
expect(formatAgo(59_000)).toBe('59s ago');
|
||||
expect(formatAgo(0)).toBe('0s ago');
|
||||
});
|
||||
|
||||
it('formats minutes', () => {
|
||||
expect(formatAgo(60_000)).toBe('1m ago');
|
||||
expect(formatAgo(59 * 60_000)).toBe('59m ago');
|
||||
});
|
||||
|
||||
it('formats hours', () => {
|
||||
expect(formatAgo(60 * 60_000)).toBe('1h ago');
|
||||
expect(formatAgo(23 * 60 * 60_000)).toBe('23h ago');
|
||||
});
|
||||
|
||||
it('formats days', () => {
|
||||
expect(formatAgo(24 * 60 * 60_000)).toBe('1d ago');
|
||||
expect(formatAgo(7 * 24 * 60 * 60_000)).toBe('7d ago');
|
||||
});
|
||||
|
||||
it('clamps negative input to 0', () => {
|
||||
expect(formatAgo(-5_000)).toBe('0s ago');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,326 @@
|
|||
/**
|
||||
* Contradiction Constellation — pure-helper coverage.
|
||||
*
|
||||
* Runs in the vitest `node` environment (no jsdom). We only test the pure
|
||||
* helpers extracted to `contradiction-helpers.ts`; the Svelte component is
|
||||
* covered indirectly because every classification, opacity, radius, and
|
||||
* color decision it renders routes through these functions.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import {
|
||||
severityColor,
|
||||
severityLabel,
|
||||
nodeColor,
|
||||
nodeRadius,
|
||||
clampTrust,
|
||||
pairOpacity,
|
||||
truncate,
|
||||
uniqueMemoryCount,
|
||||
avgTrustDelta,
|
||||
NODE_COLORS,
|
||||
KNOWN_NODE_TYPES,
|
||||
NODE_COLOR_FALLBACK,
|
||||
NODE_RADIUS_MIN,
|
||||
NODE_RADIUS_RANGE,
|
||||
SEVERITY_STRONG_COLOR,
|
||||
SEVERITY_MODERATE_COLOR,
|
||||
SEVERITY_MILD_COLOR,
|
||||
UNFOCUSED_OPACITY,
|
||||
type ContradictionLike,
|
||||
} from '../contradiction-helpers';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// severityColor — strict-greater-than thresholds at 0.5 and 0.7.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('severityColor', () => {
|
||||
it('returns mild yellow at or below 0.5', () => {
|
||||
expect(severityColor(0)).toBe(SEVERITY_MILD_COLOR);
|
||||
expect(severityColor(0.29)).toBe(SEVERITY_MILD_COLOR);
|
||||
expect(severityColor(0.3)).toBe(SEVERITY_MILD_COLOR);
|
||||
expect(severityColor(0.5)).toBe(SEVERITY_MILD_COLOR); // boundary → lower band
|
||||
});
|
||||
|
||||
it('returns moderate amber strictly above 0.5 and up to 0.7', () => {
|
||||
expect(severityColor(0.51)).toBe(SEVERITY_MODERATE_COLOR);
|
||||
expect(severityColor(0.6)).toBe(SEVERITY_MODERATE_COLOR);
|
||||
expect(severityColor(0.7)).toBe(SEVERITY_MODERATE_COLOR); // boundary → lower band
|
||||
});
|
||||
|
||||
it('returns strong red strictly above 0.7', () => {
|
||||
expect(severityColor(0.71)).toBe(SEVERITY_STRONG_COLOR);
|
||||
expect(severityColor(0.9)).toBe(SEVERITY_STRONG_COLOR);
|
||||
expect(severityColor(1.0)).toBe(SEVERITY_STRONG_COLOR);
|
||||
});
|
||||
|
||||
it('handles out-of-range numbers without crashing', () => {
|
||||
expect(severityColor(-1)).toBe(SEVERITY_MILD_COLOR);
|
||||
expect(severityColor(1.5)).toBe(SEVERITY_STRONG_COLOR);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// severityLabel — matches severityColor thresholds.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('severityLabel', () => {
|
||||
it('labels mild at 0, 0.29, 0.3, 0.5', () => {
|
||||
expect(severityLabel(0)).toBe('mild');
|
||||
expect(severityLabel(0.29)).toBe('mild');
|
||||
expect(severityLabel(0.3)).toBe('mild');
|
||||
expect(severityLabel(0.5)).toBe('mild');
|
||||
});
|
||||
|
||||
it('labels moderate at 0.51, 0.7', () => {
|
||||
expect(severityLabel(0.51)).toBe('moderate');
|
||||
expect(severityLabel(0.7)).toBe('moderate');
|
||||
});
|
||||
|
||||
it('labels strong at 0.71, 1.0', () => {
|
||||
expect(severityLabel(0.71)).toBe('strong');
|
||||
expect(severityLabel(1.0)).toBe('strong');
|
||||
});
|
||||
|
||||
it('covers all 8 ordered boundary cases from the audit', () => {
|
||||
expect(severityLabel(0)).toBe('mild');
|
||||
expect(severityLabel(0.29)).toBe('mild');
|
||||
expect(severityLabel(0.3)).toBe('mild');
|
||||
expect(severityLabel(0.5)).toBe('mild');
|
||||
expect(severityLabel(0.51)).toBe('moderate');
|
||||
expect(severityLabel(0.7)).toBe('moderate');
|
||||
expect(severityLabel(0.71)).toBe('strong');
|
||||
expect(severityLabel(1.0)).toBe('strong');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// nodeColor — 8 known types plus fallback.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('nodeColor', () => {
|
||||
it('returns distinct colors for each of the 8 known node types', () => {
|
||||
const colors = KNOWN_NODE_TYPES.map((t) => nodeColor(t));
|
||||
expect(colors.length).toBe(8);
|
||||
expect(new Set(colors).size).toBe(8); // all distinct
|
||||
});
|
||||
|
||||
it('matches the canonical palette exactly', () => {
|
||||
expect(nodeColor('fact')).toBe(NODE_COLORS.fact);
|
||||
expect(nodeColor('concept')).toBe(NODE_COLORS.concept);
|
||||
expect(nodeColor('event')).toBe(NODE_COLORS.event);
|
||||
expect(nodeColor('person')).toBe(NODE_COLORS.person);
|
||||
expect(nodeColor('place')).toBe(NODE_COLORS.place);
|
||||
expect(nodeColor('note')).toBe(NODE_COLORS.note);
|
||||
expect(nodeColor('pattern')).toBe(NODE_COLORS.pattern);
|
||||
expect(nodeColor('decision')).toBe(NODE_COLORS.decision);
|
||||
});
|
||||
|
||||
it('falls back to violet for unknown / missing types', () => {
|
||||
expect(nodeColor(undefined)).toBe(NODE_COLOR_FALLBACK);
|
||||
expect(nodeColor(null)).toBe(NODE_COLOR_FALLBACK);
|
||||
expect(nodeColor('')).toBe(NODE_COLOR_FALLBACK);
|
||||
expect(nodeColor('bogus')).toBe(NODE_COLOR_FALLBACK);
|
||||
expect(nodeColor('FACT')).toBe(NODE_COLOR_FALLBACK); // case-sensitive
|
||||
});
|
||||
|
||||
it('violet fallback equals 0x8b5cf6', () => {
|
||||
expect(NODE_COLOR_FALLBACK).toBe('#8b5cf6');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// nodeRadius + clampTrust — trust is defined on [0,1].
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('nodeRadius', () => {
|
||||
it('returns the minimum radius at trust=0', () => {
|
||||
expect(nodeRadius(0)).toBe(NODE_RADIUS_MIN);
|
||||
});
|
||||
|
||||
it('returns min + range at trust=1', () => {
|
||||
expect(nodeRadius(1)).toBe(NODE_RADIUS_MIN + NODE_RADIUS_RANGE);
|
||||
});
|
||||
|
||||
it('scales linearly in between', () => {
|
||||
expect(nodeRadius(0.5)).toBeCloseTo(NODE_RADIUS_MIN + NODE_RADIUS_RANGE * 0.5);
|
||||
});
|
||||
|
||||
it('clamps negative trust to 0 (minimum radius)', () => {
|
||||
expect(nodeRadius(-0.5)).toBe(NODE_RADIUS_MIN);
|
||||
expect(nodeRadius(-Infinity)).toBe(NODE_RADIUS_MIN);
|
||||
});
|
||||
|
||||
it('clamps >1 trust to 1 (maximum radius)', () => {
|
||||
expect(nodeRadius(2)).toBe(NODE_RADIUS_MIN + NODE_RADIUS_RANGE);
|
||||
expect(nodeRadius(Infinity)).toBe(NODE_RADIUS_MIN);
|
||||
// ^ Infinity isn't finite — falls back to min, matching "suppress suspicious data"
|
||||
});
|
||||
|
||||
it('treats NaN as minimum (suppress bad data)', () => {
|
||||
expect(nodeRadius(NaN)).toBe(NODE_RADIUS_MIN);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clampTrust', () => {
|
||||
it('returns values inside [0,1] unchanged', () => {
|
||||
expect(clampTrust(0)).toBe(0);
|
||||
expect(clampTrust(0.5)).toBe(0.5);
|
||||
expect(clampTrust(1)).toBe(1);
|
||||
});
|
||||
|
||||
it('clamps negatives to 0 and >1 to 1', () => {
|
||||
expect(clampTrust(-0.3)).toBe(0);
|
||||
expect(clampTrust(1.3)).toBe(1);
|
||||
});
|
||||
|
||||
it('collapses NaN / null / undefined / Infinity to 0', () => {
|
||||
expect(clampTrust(NaN)).toBe(0);
|
||||
expect(clampTrust(null)).toBe(0);
|
||||
expect(clampTrust(undefined)).toBe(0);
|
||||
expect(clampTrust(Infinity)).toBe(0);
|
||||
expect(clampTrust(-Infinity)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// pairOpacity — trinary: no focus = 1, focused = 1, unfocused = 0.12.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('pairOpacity', () => {
|
||||
it('returns 1 when no pair is focused (null)', () => {
|
||||
expect(pairOpacity(0, null)).toBe(1);
|
||||
expect(pairOpacity(5, null)).toBe(1);
|
||||
});
|
||||
|
||||
it('returns 1 when no pair is focused (undefined)', () => {
|
||||
expect(pairOpacity(0, undefined)).toBe(1);
|
||||
expect(pairOpacity(5, undefined)).toBe(1);
|
||||
});
|
||||
|
||||
it('returns 1 for the focused pair', () => {
|
||||
expect(pairOpacity(3, 3)).toBe(1);
|
||||
expect(pairOpacity(0, 0)).toBe(1);
|
||||
});
|
||||
|
||||
it('returns 0.12 for a non-focused pair when something is focused', () => {
|
||||
expect(pairOpacity(0, 3)).toBe(UNFOCUSED_OPACITY);
|
||||
expect(pairOpacity(7, 3)).toBe(UNFOCUSED_OPACITY);
|
||||
});
|
||||
|
||||
it('does not explode for a stale focus index that matches nothing', () => {
|
||||
// A focus index of 999 with only 5 pairs: every visible pair dims to 0.12.
|
||||
// The missing pair renders nothing (silent no-op is correct).
|
||||
for (let i = 0; i < 5; i++) {
|
||||
expect(pairOpacity(i, 999)).toBe(UNFOCUSED_OPACITY);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// truncate — length boundaries, empties, odd inputs.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('truncate', () => {
|
||||
it('returns strings shorter than max unchanged', () => {
|
||||
expect(truncate('hi', 10)).toBe('hi');
|
||||
expect(truncate('abc', 5)).toBe('abc');
|
||||
});
|
||||
|
||||
it('returns empty strings unchanged', () => {
|
||||
expect(truncate('', 5)).toBe('');
|
||||
expect(truncate('', 0)).toBe('');
|
||||
});
|
||||
|
||||
it('returns strings exactly at max unchanged', () => {
|
||||
expect(truncate('12345', 5)).toBe('12345');
|
||||
expect(truncate('abcdef', 6)).toBe('abcdef');
|
||||
});
|
||||
|
||||
it('cuts strings longer than max, appending ellipsis within budget', () => {
|
||||
expect(truncate('1234567890', 5)).toBe('1234…');
|
||||
expect(truncate('hello world', 6)).toBe('hello…');
|
||||
});
|
||||
|
||||
it('uses default max of 60', () => {
|
||||
const long = 'a'.repeat(100);
|
||||
const out = truncate(long);
|
||||
expect(out.length).toBe(60);
|
||||
expect(out.endsWith('…')).toBe(true);
|
||||
});
|
||||
|
||||
it('null / undefined inputs return empty string', () => {
|
||||
expect(truncate(null)).toBe('');
|
||||
expect(truncate(undefined)).toBe('');
|
||||
});
|
||||
|
||||
it('handles max=0 safely', () => {
|
||||
expect(truncate('any string', 0)).toBe('');
|
||||
});
|
||||
|
||||
it('handles max=1 safely — one-char budget collapses to just the ellipsis', () => {
|
||||
expect(truncate('abc', 1)).toBe('…');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// uniqueMemoryCount — union of memory_a_id + memory_b_id across pairs.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('uniqueMemoryCount', () => {
|
||||
const mkPair = (a: string, b: string): ContradictionLike => ({
|
||||
memory_a_id: a,
|
||||
memory_b_id: b,
|
||||
});
|
||||
|
||||
it('returns 0 for empty input', () => {
|
||||
expect(uniqueMemoryCount([])).toBe(0);
|
||||
});
|
||||
|
||||
it('counts both sides of every pair', () => {
|
||||
expect(uniqueMemoryCount([mkPair('a', 'b')])).toBe(2);
|
||||
expect(uniqueMemoryCount([mkPair('a', 'b'), mkPair('c', 'd')])).toBe(4);
|
||||
});
|
||||
|
||||
it('deduplicates memories that appear in multiple pairs', () => {
|
||||
// 'a' appears on both sides of two separate pairs.
|
||||
expect(uniqueMemoryCount([mkPair('a', 'b'), mkPair('a', 'c')])).toBe(3);
|
||||
expect(uniqueMemoryCount([mkPair('a', 'b'), mkPair('b', 'a')])).toBe(2);
|
||||
});
|
||||
|
||||
it('handles a memory conflicting with itself (same id both sides)', () => {
|
||||
expect(uniqueMemoryCount([mkPair('a', 'a')])).toBe(1);
|
||||
});
|
||||
|
||||
it('ignores empty-string ids', () => {
|
||||
expect(uniqueMemoryCount([mkPair('', '')])).toBe(0);
|
||||
expect(uniqueMemoryCount([mkPair('a', '')])).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// avgTrustDelta — safety against empty inputs.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('avgTrustDelta', () => {
|
||||
it('returns 0 on empty input (no NaN)', () => {
|
||||
expect(avgTrustDelta([])).toBe(0);
|
||||
});
|
||||
|
||||
it('computes mean absolute delta', () => {
|
||||
const pairs = [
|
||||
{ trust_a: 0.9, trust_b: 0.1 }, // 0.8
|
||||
{ trust_a: 0.5, trust_b: 0.3 }, // 0.2
|
||||
];
|
||||
expect(avgTrustDelta(pairs)).toBeCloseTo(0.5);
|
||||
});
|
||||
|
||||
it('takes absolute value (order does not matter)', () => {
|
||||
expect(avgTrustDelta([{ trust_a: 0.1, trust_b: 0.9 }])).toBeCloseTo(0.8);
|
||||
expect(avgTrustDelta([{ trust_a: 0.9, trust_b: 0.1 }])).toBeCloseTo(0.8);
|
||||
});
|
||||
|
||||
it('returns 0 when both sides are equal', () => {
|
||||
expect(avgTrustDelta([{ trust_a: 0.5, trust_b: 0.5 }])).toBe(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,258 @@
|
|||
/**
|
||||
* Tests for DreamInsightCard helpers.
|
||||
*
|
||||
* Pure logic only — the Svelte template is a thin wrapper around these.
|
||||
* Covers the boundaries of the gold-glow / muted novelty mapping, the
|
||||
* formatting helpers, and the source-memory link scheme.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import {
|
||||
LOW_NOVELTY_THRESHOLD,
|
||||
HIGH_NOVELTY_THRESHOLD,
|
||||
clamp01,
|
||||
noveltyBand,
|
||||
formatDurationMs,
|
||||
formatConfidencePct,
|
||||
sourceMemoryHref,
|
||||
firstSourceIds,
|
||||
extraSourceCount,
|
||||
shortMemoryId,
|
||||
} from '../dream-helpers';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// clamp01
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('clamp01', () => {
|
||||
it.each<[number | null | undefined, number]>([
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
[0.5, 0.5],
|
||||
[-0.1, 0],
|
||||
[-5, 0],
|
||||
[1.1, 1],
|
||||
[100, 1],
|
||||
[null, 0],
|
||||
[undefined, 0],
|
||||
[Number.NaN, 0],
|
||||
[Number.POSITIVE_INFINITY, 0],
|
||||
[Number.NEGATIVE_INFINITY, 0],
|
||||
])('clamp01(%s) → %s', (input, expected) => {
|
||||
expect(clamp01(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// noveltyBand — the gold/muted visual classifier
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('noveltyBand — gold-glow / muted classification', () => {
|
||||
it('has the documented thresholds', () => {
|
||||
// These constants are contractual — the component's class bindings
|
||||
// depend on them. If they change, the visual band shifts.
|
||||
expect(LOW_NOVELTY_THRESHOLD).toBe(0.3);
|
||||
expect(HIGH_NOVELTY_THRESHOLD).toBe(0.7);
|
||||
});
|
||||
|
||||
it('classifies low-novelty (< 0.3) as muted', () => {
|
||||
expect(noveltyBand(0)).toBe('low');
|
||||
expect(noveltyBand(0.1)).toBe('low');
|
||||
expect(noveltyBand(0.29)).toBe('low');
|
||||
expect(noveltyBand(0.2999)).toBe('low');
|
||||
});
|
||||
|
||||
it('classifies the boundary 0.3 exactly as neutral (NOT low)', () => {
|
||||
// The component uses `novelty < 0.3`, strictly exclusive.
|
||||
expect(noveltyBand(0.3)).toBe('neutral');
|
||||
});
|
||||
|
||||
it('classifies mid-range as neutral', () => {
|
||||
expect(noveltyBand(0.3)).toBe('neutral');
|
||||
expect(noveltyBand(0.5)).toBe('neutral');
|
||||
expect(noveltyBand(0.7)).toBe('neutral');
|
||||
});
|
||||
|
||||
it('classifies the boundary 0.7 exactly as neutral (NOT high)', () => {
|
||||
// The component uses `novelty > 0.7`, strictly exclusive.
|
||||
expect(noveltyBand(0.7)).toBe('neutral');
|
||||
});
|
||||
|
||||
it('classifies high-novelty (> 0.7) as gold/high', () => {
|
||||
expect(noveltyBand(0.71)).toBe('high');
|
||||
expect(noveltyBand(0.7001)).toBe('high');
|
||||
expect(noveltyBand(0.9)).toBe('high');
|
||||
expect(noveltyBand(1.0)).toBe('high');
|
||||
});
|
||||
|
||||
it('collapses null / undefined / NaN to the low band', () => {
|
||||
expect(noveltyBand(null)).toBe('low');
|
||||
expect(noveltyBand(undefined)).toBe('low');
|
||||
expect(noveltyBand(Number.NaN)).toBe('low');
|
||||
});
|
||||
|
||||
it('clamps out-of-range values before classifying', () => {
|
||||
// 2.0 clamps to 1.0 → high; -1 clamps to 0 → low.
|
||||
expect(noveltyBand(2.0)).toBe('high');
|
||||
expect(noveltyBand(-1)).toBe('low');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// formatDurationMs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('formatDurationMs', () => {
|
||||
it('renders sub-second values with "ms" suffix', () => {
|
||||
expect(formatDurationMs(0)).toBe('0ms');
|
||||
expect(formatDurationMs(1)).toBe('1ms');
|
||||
expect(formatDurationMs(500)).toBe('500ms');
|
||||
expect(formatDurationMs(999)).toBe('999ms');
|
||||
});
|
||||
|
||||
it('renders second-and-above values with "s" suffix, 2 decimals', () => {
|
||||
expect(formatDurationMs(1000)).toBe('1.00s');
|
||||
expect(formatDurationMs(1500)).toBe('1.50s');
|
||||
expect(formatDurationMs(15000)).toBe('15.00s');
|
||||
expect(formatDurationMs(60000)).toBe('60.00s');
|
||||
});
|
||||
|
||||
it('rounds fractional millisecond values in the "ms" band', () => {
|
||||
expect(formatDurationMs(0.4)).toBe('0ms');
|
||||
expect(formatDurationMs(12.7)).toBe('13ms');
|
||||
});
|
||||
|
||||
it('returns "0ms" for null / undefined / NaN / negative', () => {
|
||||
expect(formatDurationMs(null)).toBe('0ms');
|
||||
expect(formatDurationMs(undefined)).toBe('0ms');
|
||||
expect(formatDurationMs(Number.NaN)).toBe('0ms');
|
||||
expect(formatDurationMs(-100)).toBe('0ms');
|
||||
expect(formatDurationMs(Number.POSITIVE_INFINITY)).toBe('0ms');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// formatConfidencePct
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('formatConfidencePct', () => {
|
||||
it('renders 0 / 0.5 / 1 as whole-percent strings', () => {
|
||||
expect(formatConfidencePct(0)).toBe('0%');
|
||||
expect(formatConfidencePct(0.5)).toBe('50%');
|
||||
expect(formatConfidencePct(1)).toBe('100%');
|
||||
});
|
||||
|
||||
it('rounds intermediate values', () => {
|
||||
expect(formatConfidencePct(0.123)).toBe('12%');
|
||||
expect(formatConfidencePct(0.5049)).toBe('50%');
|
||||
expect(formatConfidencePct(0.505)).toBe('51%');
|
||||
expect(formatConfidencePct(0.999)).toBe('100%');
|
||||
});
|
||||
|
||||
it('clamps out-of-range input first', () => {
|
||||
expect(formatConfidencePct(-0.5)).toBe('0%');
|
||||
expect(formatConfidencePct(2)).toBe('100%');
|
||||
});
|
||||
|
||||
it('handles null / undefined / NaN', () => {
|
||||
expect(formatConfidencePct(null)).toBe('0%');
|
||||
expect(formatConfidencePct(undefined)).toBe('0%');
|
||||
expect(formatConfidencePct(Number.NaN)).toBe('0%');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// sourceMemoryHref
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('sourceMemoryHref — link format', () => {
|
||||
it('builds the canonical /memories/:id path with no base', () => {
|
||||
expect(sourceMemoryHref('abc123')).toBe('/memories/abc123');
|
||||
});
|
||||
|
||||
it('prepends the SvelteKit base path when provided', () => {
|
||||
expect(sourceMemoryHref('abc123', '/dashboard')).toBe(
|
||||
'/dashboard/memories/abc123',
|
||||
);
|
||||
});
|
||||
|
||||
it('handles an empty base (default behaviour)', () => {
|
||||
expect(sourceMemoryHref('abc', '')).toBe('/memories/abc');
|
||||
});
|
||||
|
||||
it('passes through full UUIDs untouched', () => {
|
||||
const uuid = '550e8400-e29b-41d4-a716-446655440000';
|
||||
expect(sourceMemoryHref(uuid)).toBe(`/memories/${uuid}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// firstSourceIds + extraSourceCount
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('firstSourceIds', () => {
|
||||
it('returns [] for empty / null / undefined inputs', () => {
|
||||
expect(firstSourceIds([])).toEqual([]);
|
||||
expect(firstSourceIds(null)).toEqual([]);
|
||||
expect(firstSourceIds(undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns the single element when array has one entry', () => {
|
||||
expect(firstSourceIds(['a'])).toEqual(['a']);
|
||||
});
|
||||
|
||||
it('returns the first 2 by default', () => {
|
||||
expect(firstSourceIds(['a', 'b', 'c', 'd'])).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('honours a custom N', () => {
|
||||
expect(firstSourceIds(['a', 'b', 'c', 'd'], 3)).toEqual(['a', 'b', 'c']);
|
||||
expect(firstSourceIds(['a', 'b', 'c'], 5)).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('returns [] for non-positive N', () => {
|
||||
expect(firstSourceIds(['a', 'b'], 0)).toEqual([]);
|
||||
expect(firstSourceIds(['a', 'b'], -1)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extraSourceCount', () => {
|
||||
it('returns 0 when there are no extras', () => {
|
||||
expect(extraSourceCount([])).toBe(0);
|
||||
expect(extraSourceCount(null)).toBe(0);
|
||||
expect(extraSourceCount(['a'])).toBe(0);
|
||||
expect(extraSourceCount(['a', 'b'])).toBe(0);
|
||||
});
|
||||
|
||||
it('returns sources.length - shown when there are extras', () => {
|
||||
expect(extraSourceCount(['a', 'b', 'c'])).toBe(1);
|
||||
expect(extraSourceCount(['a', 'b', 'c', 'd', 'e'])).toBe(3);
|
||||
});
|
||||
|
||||
it('honours a custom shown parameter', () => {
|
||||
expect(extraSourceCount(['a', 'b', 'c', 'd', 'e'], 3)).toBe(2);
|
||||
expect(extraSourceCount(['a', 'b'], 5)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// shortMemoryId
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('shortMemoryId', () => {
|
||||
it('returns the full string when 8 chars or fewer', () => {
|
||||
expect(shortMemoryId('abc')).toBe('abc');
|
||||
expect(shortMemoryId('12345678')).toBe('12345678');
|
||||
});
|
||||
|
||||
it('slices to 8 chars when longer', () => {
|
||||
expect(shortMemoryId('123456789')).toBe('12345678');
|
||||
expect(shortMemoryId('550e8400-e29b-41d4-a716-446655440000')).toBe(
|
||||
'550e8400',
|
||||
);
|
||||
});
|
||||
|
||||
it('handles empty string defensively', () => {
|
||||
expect(shortMemoryId('')).toBe('');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
/**
|
||||
* Tests for DreamStageReplay helpers.
|
||||
*
|
||||
* The Svelte component itself is rendered with CSS transforms + derived
|
||||
* state. We can't mount it in Node without jsdom, so we test the PURE
|
||||
* helpers it relies on — the same helpers also power the page's scrubber
|
||||
* and the insight card. If `clampStage` is green, the scrubber can't go
|
||||
* out of range; if `STAGE_NAMES` stays in sync with MemoryDreamer's 5
|
||||
* phases, the badge labels stay correct.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import {
|
||||
STAGE_COUNT,
|
||||
STAGE_NAMES,
|
||||
clampStage,
|
||||
stageName,
|
||||
} from '../dream-helpers';
|
||||
|
||||
describe('STAGE_NAMES — MemoryDreamer phase list', () => {
|
||||
it('has exactly 5 stages matching MemoryDreamer.run()', () => {
|
||||
expect(STAGE_COUNT).toBe(5);
|
||||
expect(STAGE_NAMES).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('lists the phases in the canonical order', () => {
|
||||
// Order is load-bearing: the stage replay animates in this sequence.
|
||||
// Replay → Cross-reference → Strengthen → Prune → Transfer.
|
||||
expect(STAGE_NAMES).toEqual([
|
||||
'Replay',
|
||||
'Cross-reference',
|
||||
'Strengthen',
|
||||
'Prune',
|
||||
'Transfer',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clampStage — valid-range enforcement', () => {
|
||||
it.each<[number, number]>([
|
||||
// Out-of-bounds low
|
||||
[0, 1],
|
||||
[-1, 1],
|
||||
[-100, 1],
|
||||
// In-range (exactly the valid stage indices)
|
||||
[1, 1],
|
||||
[2, 2],
|
||||
[3, 3],
|
||||
[4, 4],
|
||||
[5, 5],
|
||||
// Out-of-bounds high
|
||||
[6, 5],
|
||||
[7, 5],
|
||||
[100, 5],
|
||||
])('clampStage(%s) → %s', (input, expected) => {
|
||||
expect(clampStage(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it('floors fractional values before clamping', () => {
|
||||
expect(clampStage(1.9)).toBe(1);
|
||||
expect(clampStage(4.9)).toBe(4);
|
||||
expect(clampStage(5.1)).toBe(5);
|
||||
});
|
||||
|
||||
it('collapses NaN / Infinity / -Infinity to stage 1', () => {
|
||||
expect(clampStage(Number.NaN)).toBe(1);
|
||||
expect(clampStage(Number.POSITIVE_INFINITY)).toBe(1);
|
||||
expect(clampStage(Number.NEGATIVE_INFINITY)).toBe(1);
|
||||
});
|
||||
|
||||
it('returns a value usable as a 0-indexed STAGE_NAMES lookup', () => {
|
||||
// The page uses `STAGE_NAMES[stageIdx - 1]`. Every clamped value
|
||||
// must index a real name, not undefined.
|
||||
for (const raw of [-5, 0, 1, 3, 5, 10, Number.NaN]) {
|
||||
const idx = clampStage(raw);
|
||||
expect(STAGE_NAMES[idx - 1]).toBeDefined();
|
||||
expect(typeof STAGE_NAMES[idx - 1]).toBe('string');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('stageName — resolves to the visible label', () => {
|
||||
it('returns the matching name for every valid stage', () => {
|
||||
expect(stageName(1)).toBe('Replay');
|
||||
expect(stageName(2)).toBe('Cross-reference');
|
||||
expect(stageName(3)).toBe('Strengthen');
|
||||
expect(stageName(4)).toBe('Prune');
|
||||
expect(stageName(5)).toBe('Transfer');
|
||||
});
|
||||
|
||||
it('falls back to the nearest valid name for out-of-range input', () => {
|
||||
expect(stageName(0)).toBe('Replay');
|
||||
expect(stageName(-1)).toBe('Replay');
|
||||
expect(stageName(6)).toBe('Transfer');
|
||||
expect(stageName(100)).toBe('Transfer');
|
||||
});
|
||||
|
||||
it('never returns undefined, even for garbage input', () => {
|
||||
for (const raw of [Number.NaN, Number.POSITIVE_INFINITY, -Number.MAX_VALUE]) {
|
||||
expect(stageName(raw)).toBeDefined();
|
||||
expect(stageName(raw)).toMatch(/^[A-Z]/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,365 @@
|
|||
/**
|
||||
* Pure-logic tests for the Memory Hygiene / Duplicate Detection UI.
|
||||
*
|
||||
* The Svelte components themselves are render-level code (no jsdom in this
|
||||
* repo) — every ounce of behaviour worth testing is extracted into
|
||||
* `duplicates-helpers.ts` and exercised here. If this file is green, the
|
||||
* similarity bands, winner selection, suggested-action mapping, threshold
|
||||
* filtering, cluster-identity keying, and the "safe render" helpers are all
|
||||
* sound.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import {
|
||||
similarityBand,
|
||||
similarityBandColor,
|
||||
similarityBandLabel,
|
||||
retentionColor,
|
||||
pickWinner,
|
||||
suggestedActionFor,
|
||||
filterByThreshold,
|
||||
clusterKey,
|
||||
previewContent,
|
||||
formatDate,
|
||||
safeTags,
|
||||
} from '../duplicates-helpers';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Similarity band — boundaries at 0.92 (red) and 0.80 (amber).
|
||||
// The boundary value MUST land in the higher band (>= semantics).
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('similarityBand', () => {
|
||||
it('0.92 exactly → near-identical (boundary)', () => {
|
||||
expect(similarityBand(0.92)).toBe('near-identical');
|
||||
});
|
||||
|
||||
it('0.91 → strong (just below upper boundary)', () => {
|
||||
expect(similarityBand(0.91)).toBe('strong');
|
||||
});
|
||||
|
||||
it('0.80 exactly → strong (boundary)', () => {
|
||||
expect(similarityBand(0.8)).toBe('strong');
|
||||
});
|
||||
|
||||
it('0.79 → weak (just below strong boundary)', () => {
|
||||
expect(similarityBand(0.79)).toBe('weak');
|
||||
});
|
||||
|
||||
it('0.50 → weak (well below)', () => {
|
||||
expect(similarityBand(0.5)).toBe('weak');
|
||||
});
|
||||
|
||||
it('1.0 → near-identical', () => {
|
||||
expect(similarityBand(1.0)).toBe('near-identical');
|
||||
});
|
||||
|
||||
it('0.0 → weak', () => {
|
||||
expect(similarityBand(0.0)).toBe('weak');
|
||||
});
|
||||
});
|
||||
|
||||
describe('similarityBandColor', () => {
|
||||
it('near-identical → decay var (red)', () => {
|
||||
expect(similarityBandColor(0.95)).toBe('var(--color-decay)');
|
||||
});
|
||||
|
||||
it('strong → warning var (amber)', () => {
|
||||
expect(similarityBandColor(0.85)).toBe('var(--color-warning)');
|
||||
});
|
||||
|
||||
it('weak → yellow-300 literal', () => {
|
||||
expect(similarityBandColor(0.78)).toBe('#fde047');
|
||||
});
|
||||
|
||||
it('is consistent at boundary 0.92', () => {
|
||||
expect(similarityBandColor(0.92)).toBe('var(--color-decay)');
|
||||
});
|
||||
|
||||
it('is consistent at boundary 0.80', () => {
|
||||
expect(similarityBandColor(0.8)).toBe('var(--color-warning)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('similarityBandLabel', () => {
|
||||
it('labels near-identical', () => {
|
||||
expect(similarityBandLabel(0.97)).toBe('Near-identical');
|
||||
});
|
||||
|
||||
it('labels strong', () => {
|
||||
expect(similarityBandLabel(0.85)).toBe('Strong match');
|
||||
});
|
||||
|
||||
it('labels weak', () => {
|
||||
expect(similarityBandLabel(0.75)).toBe('Weak match');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Retention color — traffic-light: >0.7 green, >0.4 amber, else red.
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('retentionColor', () => {
|
||||
it('0.85 → green', () => expect(retentionColor(0.85)).toBe('#10b981'));
|
||||
it('0.50 → amber', () => expect(retentionColor(0.5)).toBe('#f59e0b'));
|
||||
it('0.30 → red', () => expect(retentionColor(0.3)).toBe('#ef4444'));
|
||||
it('boundary 0.70 → amber (strict >)', () => expect(retentionColor(0.7)).toBe('#f59e0b'));
|
||||
it('boundary 0.40 → red (strict >)', () => expect(retentionColor(0.4)).toBe('#ef4444'));
|
||||
it('0.0 → red', () => expect(retentionColor(0)).toBe('#ef4444'));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Winner selection — highest retention wins; ties → earliest index; empty
|
||||
// list → null; NaN retentions never win.
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('pickWinner', () => {
|
||||
it('picks highest retention', () => {
|
||||
const mem = [
|
||||
{ id: 'a', retention: 0.3 },
|
||||
{ id: 'b', retention: 0.9 },
|
||||
{ id: 'c', retention: 0.5 },
|
||||
];
|
||||
expect(pickWinner(mem)?.id).toBe('b');
|
||||
});
|
||||
|
||||
it('tie-break: earliest wins (stable)', () => {
|
||||
const mem = [
|
||||
{ id: 'a', retention: 0.8 },
|
||||
{ id: 'b', retention: 0.8 },
|
||||
{ id: 'c', retention: 0.7 },
|
||||
];
|
||||
expect(pickWinner(mem)?.id).toBe('a');
|
||||
});
|
||||
|
||||
it('three-way tie: earliest wins', () => {
|
||||
const mem = [
|
||||
{ id: 'x', retention: 0.5 },
|
||||
{ id: 'y', retention: 0.5 },
|
||||
{ id: 'z', retention: 0.5 },
|
||||
];
|
||||
expect(pickWinner(mem)?.id).toBe('x');
|
||||
});
|
||||
|
||||
it('all retention = 0: earliest wins (not null)', () => {
|
||||
const mem = [
|
||||
{ id: 'a', retention: 0 },
|
||||
{ id: 'b', retention: 0 },
|
||||
];
|
||||
expect(pickWinner(mem)?.id).toBe('a');
|
||||
});
|
||||
|
||||
it('single-member cluster: that member wins', () => {
|
||||
const mem = [{ id: 'solo', retention: 0.42 }];
|
||||
expect(pickWinner(mem)?.id).toBe('solo');
|
||||
});
|
||||
|
||||
it('empty cluster: returns null', () => {
|
||||
expect(pickWinner([])).toBeNull();
|
||||
});
|
||||
|
||||
it('NaN retention never wins over a real one', () => {
|
||||
const mem = [
|
||||
{ id: 'nan', retention: Number.NaN },
|
||||
{ id: 'real', retention: 0.1 },
|
||||
];
|
||||
expect(pickWinner(mem)?.id).toBe('real');
|
||||
});
|
||||
|
||||
it('all NaN retentions: earliest wins (stable fallback)', () => {
|
||||
const mem = [
|
||||
{ id: 'a', retention: Number.NaN },
|
||||
{ id: 'b', retention: Number.NaN },
|
||||
];
|
||||
expect(pickWinner(mem)?.id).toBe('a');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Suggested action — >=0.92 merge, <0.85 review, 0.85..<0.92 null (caller
|
||||
// honors upstream).
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('suggestedActionFor', () => {
|
||||
it('0.95 → merge', () => expect(suggestedActionFor(0.95)).toBe('merge'));
|
||||
it('0.92 exactly → merge (boundary)', () => expect(suggestedActionFor(0.92)).toBe('merge'));
|
||||
it('0.91 → null (ambiguous corridor)', () => expect(suggestedActionFor(0.91)).toBeNull());
|
||||
it('0.85 exactly → null (corridor bottom boundary)', () =>
|
||||
expect(suggestedActionFor(0.85)).toBeNull());
|
||||
it('0.849 → review (just below corridor)', () =>
|
||||
expect(suggestedActionFor(0.849)).toBe('review'));
|
||||
it('0.70 → review', () => expect(suggestedActionFor(0.7)).toBe('review'));
|
||||
it('0.0 → review', () => expect(suggestedActionFor(0)).toBe('review'));
|
||||
it('1.0 → merge', () => expect(suggestedActionFor(1.0)).toBe('merge'));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Threshold filter — strict >=.
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('filterByThreshold', () => {
|
||||
const clusters = [
|
||||
{ similarity: 0.96, memories: [{ id: '1', retention: 1 }] },
|
||||
{ similarity: 0.88, memories: [{ id: '2', retention: 1 }] },
|
||||
{ similarity: 0.78, memories: [{ id: '3', retention: 1 }] },
|
||||
];
|
||||
|
||||
it('0.80 keeps 0.96 and 0.88 (drops 0.78)', () => {
|
||||
const out = filterByThreshold(clusters, 0.8);
|
||||
expect(out.map((c) => c.similarity)).toEqual([0.96, 0.88]);
|
||||
});
|
||||
|
||||
it('boundary: threshold = 0.88 keeps 0.88 (>=)', () => {
|
||||
const out = filterByThreshold(clusters, 0.88);
|
||||
expect(out.map((c) => c.similarity)).toEqual([0.96, 0.88]);
|
||||
});
|
||||
|
||||
it('boundary: threshold = 0.881 drops 0.88', () => {
|
||||
const out = filterByThreshold(clusters, 0.881);
|
||||
expect(out.map((c) => c.similarity)).toEqual([0.96]);
|
||||
});
|
||||
|
||||
it('0.95 (max) keeps only 0.96', () => {
|
||||
const out = filterByThreshold(clusters, 0.95);
|
||||
expect(out.map((c) => c.similarity)).toEqual([0.96]);
|
||||
});
|
||||
|
||||
it('0.70 (min) keeps all three', () => {
|
||||
const out = filterByThreshold(clusters, 0.7);
|
||||
expect(out).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('empty input → empty output', () => {
|
||||
expect(filterByThreshold([], 0.8)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cluster identity — stable across order shuffles and re-fetches.
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('clusterKey', () => {
|
||||
it('identical member sets → identical keys (order-independent)', () => {
|
||||
const a = [
|
||||
{ id: 'a', retention: 0 },
|
||||
{ id: 'b', retention: 0 },
|
||||
{ id: 'c', retention: 0 },
|
||||
];
|
||||
const b = [
|
||||
{ id: 'c', retention: 0 },
|
||||
{ id: 'a', retention: 0 },
|
||||
{ id: 'b', retention: 0 },
|
||||
];
|
||||
expect(clusterKey(a)).toBe(clusterKey(b));
|
||||
});
|
||||
|
||||
it('differing members → differing keys', () => {
|
||||
const a = [
|
||||
{ id: 'a', retention: 0 },
|
||||
{ id: 'b', retention: 0 },
|
||||
];
|
||||
const b = [
|
||||
{ id: 'a', retention: 0 },
|
||||
{ id: 'c', retention: 0 },
|
||||
];
|
||||
expect(clusterKey(a)).not.toBe(clusterKey(b));
|
||||
});
|
||||
|
||||
it('does not mutate input order', () => {
|
||||
const mem = [
|
||||
{ id: 'z', retention: 0 },
|
||||
{ id: 'a', retention: 0 },
|
||||
];
|
||||
clusterKey(mem);
|
||||
expect(mem.map((m) => m.id)).toEqual(['z', 'a']);
|
||||
});
|
||||
|
||||
it('empty cluster → empty string', () => {
|
||||
expect(clusterKey([])).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// previewContent — trim + collapse whitespace + truncate at 80.
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('previewContent', () => {
|
||||
it('short content: unchanged', () => {
|
||||
expect(previewContent('hello world')).toBe('hello world');
|
||||
});
|
||||
|
||||
it('collapses internal whitespace', () => {
|
||||
expect(previewContent(' hello world ')).toBe('hello world');
|
||||
});
|
||||
|
||||
it('truncates with ellipsis', () => {
|
||||
const long = 'a'.repeat(120);
|
||||
const out = previewContent(long);
|
||||
expect(out.length).toBe(81); // 80 + ellipsis
|
||||
expect(out.endsWith('…')).toBe(true);
|
||||
});
|
||||
|
||||
it('null-safe', () => {
|
||||
expect(previewContent(null)).toBe('');
|
||||
expect(previewContent(undefined)).toBe('');
|
||||
});
|
||||
|
||||
it('honors custom max', () => {
|
||||
expect(previewContent('abcdefghij', 5)).toBe('abcde…');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// formatDate — valid ISO → formatted; everything else → empty.
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('formatDate', () => {
|
||||
it('valid ISO → non-empty formatted string', () => {
|
||||
const out = formatDate('2026-04-14T11:02:00Z');
|
||||
expect(out.length).toBeGreaterThan(0);
|
||||
expect(out).not.toBe('Invalid Date');
|
||||
});
|
||||
|
||||
it('empty string → empty', () => {
|
||||
expect(formatDate('')).toBe('');
|
||||
});
|
||||
|
||||
it('null → empty', () => {
|
||||
expect(formatDate(null)).toBe('');
|
||||
});
|
||||
|
||||
it('undefined → empty', () => {
|
||||
expect(formatDate(undefined)).toBe('');
|
||||
});
|
||||
|
||||
it('garbage string → empty (no "Invalid Date" leak)', () => {
|
||||
expect(formatDate('not-a-date')).toBe('');
|
||||
});
|
||||
|
||||
it('non-string input → empty (defensive)', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect(formatDate(12345 as any)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// safeTags — tolerant of undefined / non-array / empty.
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('safeTags', () => {
|
||||
it('normal array: slices to limit', () => {
|
||||
expect(safeTags(['a', 'b', 'c', 'd', 'e'], 3)).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('undefined → []', () => {
|
||||
expect(safeTags(undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it('null → []', () => {
|
||||
expect(safeTags(null)).toEqual([]);
|
||||
});
|
||||
|
||||
it('empty array → []', () => {
|
||||
expect(safeTags([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('non-array (defensive) → []', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect(safeTags('bad' as any)).toEqual([]);
|
||||
});
|
||||
|
||||
it('honors default limit = 4', () => {
|
||||
expect(safeTags(['a', 'b', 'c', 'd', 'e', 'f'])).toEqual(['a', 'b', 'c', 'd']);
|
||||
});
|
||||
});
|
||||
255
apps/dashboard/src/lib/components/__tests__/EvidenceCard.test.ts
Normal file
255
apps/dashboard/src/lib/components/__tests__/EvidenceCard.test.ts
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
/**
|
||||
* EvidenceCard — pure-logic coverage.
|
||||
*
|
||||
* The component itself mounts Svelte, which vitest cannot do in a node
|
||||
* environment. Every piece of logic that was reachable via props has been
|
||||
* extracted to `reasoning-helpers.ts`; this file exhaustively exercises
|
||||
* those helpers through the same import surface EvidenceCard uses. If
|
||||
* this file is green, the card's visual output is a 1:1 function of the
|
||||
* helper output.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import {
|
||||
ROLE_META,
|
||||
roleMetaFor,
|
||||
trustColor,
|
||||
trustPercent,
|
||||
clampTrust,
|
||||
nodeTypeColor,
|
||||
formatDate,
|
||||
shortenId,
|
||||
CONFIDENCE_EMERALD,
|
||||
CONFIDENCE_AMBER,
|
||||
CONFIDENCE_RED,
|
||||
DEFAULT_NODE_TYPE_COLOR,
|
||||
type EvidenceRole,
|
||||
} from '../reasoning-helpers';
|
||||
import { NODE_TYPE_COLORS } from '$types';
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// clampTrust + trustPercent — numeric contract
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('clampTrust — 0-1 display range', () => {
|
||||
it.each<[number, number]>([
|
||||
[0, 0],
|
||||
[0.5, 0.5],
|
||||
[1, 1],
|
||||
[-0.1, 0],
|
||||
[-1, 0],
|
||||
[1.2, 1],
|
||||
[999, 1],
|
||||
])('clamps %f → %f', (input, expected) => {
|
||||
expect(clampTrust(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it('returns 0 for NaN (defensive — avoids NaN% in the UI)', () => {
|
||||
expect(clampTrust(Number.NaN)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 0 for non-finite inputs (+/-Infinity) — safe default', () => {
|
||||
// Infinity indicates upstream garbage — degrade to empty bar rather
|
||||
// than saturate the UI to 100%.
|
||||
expect(clampTrust(-Infinity)).toBe(0);
|
||||
expect(clampTrust(Infinity)).toBe(0);
|
||||
});
|
||||
|
||||
it('is idempotent (clamp of clamp is the same)', () => {
|
||||
for (const v of [-0.5, 0, 0.3, 0.75, 1, 2]) {
|
||||
expect(clampTrust(clampTrust(v))).toBe(clampTrust(v));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('trustPercent — 0-100 rendering', () => {
|
||||
it.each<[number, number]>([
|
||||
[0, 0],
|
||||
[0.5, 50],
|
||||
[1, 100],
|
||||
[-0.1, 0],
|
||||
[1.2, 100],
|
||||
])('converts trust %f → %f%%', (t, expected) => {
|
||||
expect(trustPercent(t)).toBe(expected);
|
||||
});
|
||||
|
||||
it('handles NaN without producing NaN', () => {
|
||||
expect(trustPercent(Number.NaN)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// trustColor — band boundaries for the card's trust bar
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('trustColor — boundary analysis', () => {
|
||||
it.each<[number, string]>([
|
||||
// Emerald band: strictly > 0.75 → > 75%
|
||||
[1.0, CONFIDENCE_EMERALD],
|
||||
[0.9, CONFIDENCE_EMERALD],
|
||||
[0.751, CONFIDENCE_EMERALD],
|
||||
// Amber band: 0.40 ≤ t ≤ 0.75
|
||||
[0.75, CONFIDENCE_AMBER], // boundary — amber at exactly 75%
|
||||
[0.5, CONFIDENCE_AMBER],
|
||||
[0.4, CONFIDENCE_AMBER], // boundary — amber at exactly 40%
|
||||
// Red band: < 0.40
|
||||
[0.399, CONFIDENCE_RED],
|
||||
[0.2, CONFIDENCE_RED],
|
||||
[0, CONFIDENCE_RED],
|
||||
])('trust %f → %s', (t, expected) => {
|
||||
expect(trustColor(t)).toBe(expected);
|
||||
});
|
||||
|
||||
it('clamps negative to red and super-high to emerald (defensive)', () => {
|
||||
expect(trustColor(-0.5)).toBe(CONFIDENCE_RED);
|
||||
expect(trustColor(1.5)).toBe(CONFIDENCE_EMERALD);
|
||||
});
|
||||
|
||||
it('returns red for NaN (lowest-confidence fallback)', () => {
|
||||
expect(trustColor(Number.NaN)).toBe(CONFIDENCE_RED);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Role metadata — label + accent + icon
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('ROLE_META — completeness and shape', () => {
|
||||
const roles: EvidenceRole[] = ['primary', 'supporting', 'contradicting', 'superseded'];
|
||||
|
||||
it('defines an entry for every role', () => {
|
||||
for (const r of roles) {
|
||||
expect(ROLE_META[r]).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(roles)('%s has non-empty label + icon', (r) => {
|
||||
const meta = ROLE_META[r];
|
||||
expect(meta.label.length).toBeGreaterThan(0);
|
||||
expect(meta.icon.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('maps to the expected accent tokens used by Tailwind (synapse/recall/decay/muted)', () => {
|
||||
expect(ROLE_META.primary.accent).toBe('synapse');
|
||||
expect(ROLE_META.supporting.accent).toBe('recall');
|
||||
expect(ROLE_META.contradicting.accent).toBe('decay');
|
||||
expect(ROLE_META.superseded.accent).toBe('muted');
|
||||
});
|
||||
|
||||
it('accents are unique across roles (each role is visually distinct)', () => {
|
||||
const accents = roles.map((r) => ROLE_META[r].accent);
|
||||
expect(new Set(accents).size).toBe(4);
|
||||
});
|
||||
|
||||
it('icons are unique across roles', () => {
|
||||
const icons = roles.map((r) => ROLE_META[r].icon);
|
||||
expect(new Set(icons).size).toBe(4);
|
||||
});
|
||||
|
||||
it('labels are human-readable (first letter capital, no accents on the word)', () => {
|
||||
for (const r of roles) {
|
||||
const label = ROLE_META[r].label;
|
||||
expect(label[0]).toBe(label[0].toUpperCase());
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('roleMetaFor — lookup with defensive fallback', () => {
|
||||
it('returns the exact entry for a known role', () => {
|
||||
expect(roleMetaFor('primary')).toBe(ROLE_META.primary);
|
||||
expect(roleMetaFor('contradicting')).toBe(ROLE_META.contradicting);
|
||||
});
|
||||
|
||||
it('falls back to Supporting when handed an unknown role (deep_reference could add new ones)', () => {
|
||||
expect(roleMetaFor('unknown-role')).toBe(ROLE_META.supporting);
|
||||
expect(roleMetaFor('')).toBe(ROLE_META.supporting);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// nodeTypeColor — palette lookup with fallback
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('nodeTypeColor — palette lookup', () => {
|
||||
it('returns the fallback colour when nodeType is undefined/null/empty', () => {
|
||||
expect(nodeTypeColor(undefined)).toBe(DEFAULT_NODE_TYPE_COLOR);
|
||||
expect(nodeTypeColor(null)).toBe(DEFAULT_NODE_TYPE_COLOR);
|
||||
expect(nodeTypeColor('')).toBe(DEFAULT_NODE_TYPE_COLOR);
|
||||
});
|
||||
|
||||
it('returns the palette entry for every known NODE_TYPE_COLORS key', () => {
|
||||
for (const [type, colour] of Object.entries(NODE_TYPE_COLORS)) {
|
||||
expect(nodeTypeColor(type)).toBe(colour);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns the fallback for an unknown nodeType', () => {
|
||||
expect(nodeTypeColor('quantum-state')).toBe(DEFAULT_NODE_TYPE_COLOR);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// formatDate — invalid-date handling (the real bug fixed here)
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('formatDate — ISO parsing with graceful degradation', () => {
|
||||
it('formats a valid ISO date into a locale string', () => {
|
||||
const out = formatDate('2026-04-20T12:00:00.000Z', 'en-US');
|
||||
// Example: "Apr 20, 2026"
|
||||
expect(out).toMatch(/2026/);
|
||||
expect(out).toMatch(/Apr/);
|
||||
});
|
||||
|
||||
it('returns em-dash for empty / null / undefined', () => {
|
||||
expect(formatDate('')).toBe('—');
|
||||
expect(formatDate(null)).toBe('—');
|
||||
expect(formatDate(undefined)).toBe('—');
|
||||
expect(formatDate(' ')).toBe('—');
|
||||
});
|
||||
|
||||
it('returns the original string when the input is unparseable (never "Invalid Date")', () => {
|
||||
// Regression: `new Date('not-a-date').toLocaleDateString()` returned
|
||||
// the literal text "Invalid Date" — EvidenceCard rendered that. Now
|
||||
// we surface the raw string so a reviewer can tell it was garbage.
|
||||
const garbage = 'not-a-date';
|
||||
expect(formatDate(garbage)).toBe(garbage);
|
||||
expect(formatDate(garbage)).not.toBe('Invalid Date');
|
||||
});
|
||||
|
||||
it('handles ISO dates without time component', () => {
|
||||
const out = formatDate('2026-01-15', 'en-US');
|
||||
expect(out).toMatch(/2026/);
|
||||
});
|
||||
|
||||
it('is pure — no global mutation between calls', () => {
|
||||
const a = formatDate('2026-04-20T00:00:00.000Z', 'en-US');
|
||||
const b = formatDate('2026-04-20T00:00:00.000Z', 'en-US');
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// shortenId — UUID → #abcdef01
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('shortenId — 8-char display prefix', () => {
|
||||
it('returns an 8-char prefix for a standard UUID', () => {
|
||||
expect(shortenId('a1b2c3d4-e5f6-0000-0000-000000000000')).toBe('a1b2c3d4');
|
||||
});
|
||||
|
||||
it('returns the full string when already ≤ 8 chars', () => {
|
||||
expect(shortenId('abc')).toBe('abc');
|
||||
expect(shortenId('12345678')).toBe('12345678');
|
||||
});
|
||||
|
||||
it('handles null/undefined/empty gracefully', () => {
|
||||
expect(shortenId(null)).toBe('');
|
||||
expect(shortenId(undefined)).toBe('');
|
||||
expect(shortenId('')).toBe('');
|
||||
});
|
||||
|
||||
it('respects a custom length parameter', () => {
|
||||
expect(shortenId('abcdefghij', 4)).toBe('abcd');
|
||||
expect(shortenId('abcdefghij', 10)).toBe('abcdefghij');
|
||||
});
|
||||
});
|
||||
311
apps/dashboard/src/lib/components/__tests__/FSRSCalendar.test.ts
Normal file
311
apps/dashboard/src/lib/components/__tests__/FSRSCalendar.test.ts
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
/**
|
||||
* Tests for schedule / FSRS calendar helpers. These are the pure-logic core
|
||||
* of the `schedule` page + `FSRSCalendar.svelte` component — the Svelte
|
||||
* runtime is not exercised here (vitest runs `environment: node`, no jsdom).
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { Memory } from '$types';
|
||||
import {
|
||||
MS_DAY,
|
||||
startOfDay,
|
||||
daysBetween,
|
||||
isoDate,
|
||||
classifyUrgency,
|
||||
daysUntilReview,
|
||||
weekBucketRange,
|
||||
avgRetention,
|
||||
gridCellPosition,
|
||||
gridStartForAnchor,
|
||||
computeScheduleStats,
|
||||
} from '../schedule-helpers';
|
||||
|
||||
function makeMemory(overrides: Partial<Memory> = {}): Memory {
|
||||
return {
|
||||
id: 'm-' + Math.random().toString(36).slice(2, 8),
|
||||
content: 'test memory',
|
||||
nodeType: 'fact',
|
||||
tags: [],
|
||||
retentionStrength: 0.7,
|
||||
storageStrength: 0.5,
|
||||
retrievalStrength: 0.8,
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Fixed anchor: 2026-04-20 12:00 local so offsets don't straddle midnight
|
||||
// in the default test runner's tz. All relative timestamps are derived from
|
||||
// this anchor to keep tests tz-independent.
|
||||
function anchor(): Date {
|
||||
const d = new Date(2026, 3, 20, 12, 0, 0, 0); // Mon Apr 20 2026 12:00 local
|
||||
return d;
|
||||
}
|
||||
|
||||
function offsetDays(base: Date, days: number, hour = 12): Date {
|
||||
const d = new Date(base);
|
||||
d.setDate(d.getDate() + days);
|
||||
d.setHours(hour, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
describe('startOfDay', () => {
|
||||
it('zeros hours / minutes / seconds / ms', () => {
|
||||
const d = new Date(2026, 3, 20, 14, 35, 27, 999);
|
||||
const s = startOfDay(d);
|
||||
expect(s.getHours()).toBe(0);
|
||||
expect(s.getMinutes()).toBe(0);
|
||||
expect(s.getSeconds()).toBe(0);
|
||||
expect(s.getMilliseconds()).toBe(0);
|
||||
expect(s.getFullYear()).toBe(2026);
|
||||
expect(s.getMonth()).toBe(3);
|
||||
expect(s.getDate()).toBe(20);
|
||||
});
|
||||
|
||||
it('does not mutate its input', () => {
|
||||
const input = new Date(2026, 3, 20, 14, 35);
|
||||
const before = input.getTime();
|
||||
startOfDay(input);
|
||||
expect(input.getTime()).toBe(before);
|
||||
});
|
||||
|
||||
it('accepts an ISO string', () => {
|
||||
const s = startOfDay('2026-04-20T14:35:00');
|
||||
expect(s.getHours()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('daysBetween', () => {
|
||||
it('returns 0 for the same calendar day at different hours', () => {
|
||||
const a = new Date(2026, 3, 20, 0, 0);
|
||||
const b = new Date(2026, 3, 20, 23, 59);
|
||||
expect(daysBetween(a, b)).toBe(0);
|
||||
expect(daysBetween(b, a)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns positive for future, negative for past', () => {
|
||||
const today = anchor();
|
||||
expect(daysBetween(offsetDays(today, 3), today)).toBe(3);
|
||||
expect(daysBetween(offsetDays(today, -3), today)).toBe(-3);
|
||||
});
|
||||
|
||||
it('is day-granular across the midnight boundary', () => {
|
||||
const midnight = new Date(2026, 3, 20, 0, 0, 0, 0);
|
||||
const justBefore = new Date(2026, 3, 19, 23, 59, 59, 999);
|
||||
expect(daysBetween(midnight, justBefore)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isoDate', () => {
|
||||
it('formats as YYYY-MM-DD with zero-padding in LOCAL time', () => {
|
||||
expect(isoDate(new Date(2026, 0, 5))).toBe('2026-01-05'); // jan 5
|
||||
expect(isoDate(new Date(2026, 11, 31))).toBe('2026-12-31');
|
||||
});
|
||||
|
||||
it('uses local day even for late-evening UTC-crossing timestamps', () => {
|
||||
// This is the whole reason isoDate uses get* not getUTC*: calendar cells
|
||||
// should match the user's perceived day.
|
||||
const d = new Date(2026, 3, 20, 23, 30); // apr 20 23:30 local
|
||||
expect(isoDate(d)).toBe('2026-04-20');
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyUrgency', () => {
|
||||
const now = anchor();
|
||||
|
||||
it('returns "none" for missing nextReviewAt', () => {
|
||||
expect(classifyUrgency(now, null)).toBe('none');
|
||||
expect(classifyUrgency(now, undefined)).toBe('none');
|
||||
expect(classifyUrgency(now, '')).toBe('none');
|
||||
});
|
||||
|
||||
it('returns "none" for unparseable ISO strings', () => {
|
||||
expect(classifyUrgency(now, 'not-a-date')).toBe('none');
|
||||
});
|
||||
|
||||
it('classifies overdue when due date is strictly before today', () => {
|
||||
expect(classifyUrgency(now, offsetDays(now, -1).toISOString())).toBe('overdue');
|
||||
expect(classifyUrgency(now, offsetDays(now, -5).toISOString())).toBe('overdue');
|
||||
});
|
||||
|
||||
it('classifies today when due date is the same calendar day', () => {
|
||||
// Same day, earlier hour — still today, NOT overdue (day-granular).
|
||||
const earlier = new Date(now);
|
||||
earlier.setHours(3, 0);
|
||||
expect(classifyUrgency(now, earlier.toISOString())).toBe('today');
|
||||
const later = new Date(now);
|
||||
later.setHours(22, 0);
|
||||
expect(classifyUrgency(now, later.toISOString())).toBe('today');
|
||||
});
|
||||
|
||||
it('classifies 1..=7 days out as "week"', () => {
|
||||
expect(classifyUrgency(now, offsetDays(now, 1).toISOString())).toBe('week');
|
||||
expect(classifyUrgency(now, offsetDays(now, 7).toISOString())).toBe('week');
|
||||
});
|
||||
|
||||
it('classifies 8+ days out as "future"', () => {
|
||||
expect(classifyUrgency(now, offsetDays(now, 8).toISOString())).toBe('future');
|
||||
expect(classifyUrgency(now, offsetDays(now, 30).toISOString())).toBe('future');
|
||||
});
|
||||
|
||||
it('boundary at midnight: 1 second after midnight tomorrow is "week" not "today"', () => {
|
||||
const tomorrowMidnight = startOfDay(offsetDays(now, 1, 0));
|
||||
tomorrowMidnight.setSeconds(1);
|
||||
expect(classifyUrgency(now, tomorrowMidnight.toISOString())).toBe('week');
|
||||
});
|
||||
});
|
||||
|
||||
describe('daysUntilReview', () => {
|
||||
const now = anchor();
|
||||
|
||||
it('returns null for missing / invalid input', () => {
|
||||
expect(daysUntilReview(now, null)).toBeNull();
|
||||
expect(daysUntilReview(now, undefined)).toBeNull();
|
||||
expect(daysUntilReview(now, 'garbage')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns 0 for today', () => {
|
||||
expect(daysUntilReview(now, now.toISOString())).toBe(0);
|
||||
});
|
||||
|
||||
it('returns signed integer days', () => {
|
||||
expect(daysUntilReview(now, offsetDays(now, 5).toISOString())).toBe(5);
|
||||
expect(daysUntilReview(now, offsetDays(now, -3).toISOString())).toBe(-3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('weekBucketRange', () => {
|
||||
it('returns Sunday→Sunday exclusive for any weekday', () => {
|
||||
// Apr 20 2026 is a Monday. The week starts on Sunday Apr 19.
|
||||
const mon = new Date(2026, 3, 20, 14, 0);
|
||||
const { start, end } = weekBucketRange(mon);
|
||||
expect(start.getDay()).toBe(0); // Sunday
|
||||
expect(start.getDate()).toBe(19);
|
||||
expect(end.getDate()).toBe(26); // next Sunday
|
||||
expect(end.getTime() - start.getTime()).toBe(7 * MS_DAY);
|
||||
});
|
||||
|
||||
it('for Sunday input, returns that same Sunday as start', () => {
|
||||
const sun = new Date(2026, 3, 19, 10, 0); // Sun Apr 19 2026
|
||||
const { start } = weekBucketRange(sun);
|
||||
expect(start.getDate()).toBe(19);
|
||||
});
|
||||
});
|
||||
|
||||
describe('avgRetention', () => {
|
||||
it('returns 0 for empty array (no NaN)', () => {
|
||||
expect(avgRetention([])).toBe(0);
|
||||
expect(Number.isNaN(avgRetention([]))).toBe(false);
|
||||
});
|
||||
|
||||
it('returns the single value for a length-1 list', () => {
|
||||
expect(avgRetention([makeMemory({ retentionStrength: 0.42 })])).toBeCloseTo(0.42);
|
||||
});
|
||||
|
||||
it('returns the mean for a mixed list', () => {
|
||||
const ms = [
|
||||
makeMemory({ retentionStrength: 0.2 }),
|
||||
makeMemory({ retentionStrength: 0.8 }),
|
||||
makeMemory({ retentionStrength: 0.5 }),
|
||||
];
|
||||
expect(avgRetention(ms)).toBeCloseTo(0.5);
|
||||
});
|
||||
|
||||
it('tolerates missing retentionStrength (treat as 0)', () => {
|
||||
const ms = [
|
||||
makeMemory({ retentionStrength: 1.0 }),
|
||||
makeMemory({ retentionStrength: undefined as unknown as number }),
|
||||
];
|
||||
expect(avgRetention(ms)).toBeCloseTo(0.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gridCellPosition', () => {
|
||||
it('maps row-major: index 0 → (0,0), index 7 → (1,0), index 41 → (5,6)', () => {
|
||||
expect(gridCellPosition(0)).toEqual({ row: 0, col: 0 });
|
||||
expect(gridCellPosition(6)).toEqual({ row: 0, col: 6 });
|
||||
expect(gridCellPosition(7)).toEqual({ row: 1, col: 0 });
|
||||
expect(gridCellPosition(15)).toEqual({ row: 2, col: 1 });
|
||||
expect(gridCellPosition(41)).toEqual({ row: 5, col: 6 });
|
||||
});
|
||||
|
||||
it('returns null for out-of-range or non-integer indices', () => {
|
||||
expect(gridCellPosition(-1)).toBeNull();
|
||||
expect(gridCellPosition(42)).toBeNull();
|
||||
expect(gridCellPosition(100)).toBeNull();
|
||||
expect(gridCellPosition(3.5)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('gridStartForAnchor', () => {
|
||||
it('returns a Sunday at or before anchor-14 days', () => {
|
||||
// Apr 20 2026 (Mon) → anchor-14 = Apr 6 2026 (Mon) → back to Sun Apr 5.
|
||||
const start = gridStartForAnchor(anchor());
|
||||
expect(start.getDay()).toBe(0);
|
||||
expect(start.getFullYear()).toBe(2026);
|
||||
expect(start.getMonth()).toBe(3);
|
||||
expect(start.getDate()).toBe(5);
|
||||
expect(start.getHours()).toBe(0);
|
||||
});
|
||||
|
||||
it('includes today in the 6-week window (row 2 or 3)', () => {
|
||||
const today = anchor();
|
||||
const start = gridStartForAnchor(today);
|
||||
const delta = daysBetween(today, start);
|
||||
expect(delta).toBeGreaterThanOrEqual(14);
|
||||
expect(delta).toBeLessThan(42);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeScheduleStats', () => {
|
||||
const now = anchor();
|
||||
|
||||
it('zeros everything for an empty corpus', () => {
|
||||
const s = computeScheduleStats(now, []);
|
||||
expect(s).toEqual({
|
||||
overdue: 0,
|
||||
dueToday: 0,
|
||||
dueThisWeek: 0,
|
||||
dueThisMonth: 0,
|
||||
avgDays: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('counts each bucket independently (today ⊂ week ⊂ month)', () => {
|
||||
const ms = [
|
||||
makeMemory({ nextReviewAt: offsetDays(now, -2).toISOString() }), // overdue
|
||||
makeMemory({ nextReviewAt: new Date(now).toISOString() }), // today
|
||||
makeMemory({ nextReviewAt: offsetDays(now, 3).toISOString() }), // week
|
||||
makeMemory({ nextReviewAt: offsetDays(now, 15).toISOString() }), // month
|
||||
makeMemory({ nextReviewAt: offsetDays(now, 45).toISOString() }), // out of month
|
||||
];
|
||||
const s = computeScheduleStats(now, ms);
|
||||
expect(s.overdue).toBe(1);
|
||||
expect(s.dueToday).toBe(2); // overdue + today (delta <= 0)
|
||||
expect(s.dueThisWeek).toBe(3); // overdue + today + week
|
||||
expect(s.dueThisMonth).toBe(4); // overdue + today + week + month
|
||||
});
|
||||
|
||||
it('skips memories without a nextReviewAt or with unparseable dates', () => {
|
||||
const ms = [
|
||||
makeMemory({ nextReviewAt: undefined }),
|
||||
makeMemory({ nextReviewAt: 'bogus' }),
|
||||
makeMemory({ nextReviewAt: offsetDays(now, 2).toISOString() }),
|
||||
];
|
||||
const s = computeScheduleStats(now, ms);
|
||||
expect(s.dueThisWeek).toBe(1);
|
||||
});
|
||||
|
||||
it('computes average days across future-only memories', () => {
|
||||
const ms = [
|
||||
makeMemory({ nextReviewAt: offsetDays(now, -5).toISOString() }), // excluded (past)
|
||||
makeMemory({ nextReviewAt: offsetDays(now, 2).toISOString() }),
|
||||
makeMemory({ nextReviewAt: offsetDays(now, 4).toISOString() }),
|
||||
];
|
||||
const s = computeScheduleStats(now, ms);
|
||||
// avgDays is measured from today-at-midnight (not now-mid-day), so a
|
||||
// review tomorrow at noon is 1.5 days out. Two memories at +2d and +4d
|
||||
// (both hour=12) → (2.5 + 4.5) / 2 = 3.5.
|
||||
expect(s.avgDays).toBeCloseTo(3.5, 2);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,417 @@
|
|||
/**
|
||||
* Unit tests for importance-helpers — the pure logic backing
|
||||
* ImportanceRadar.svelte + importance/+page.svelte.
|
||||
*
|
||||
* Runs in the vitest `node` environment (no jsdom). We exercise:
|
||||
* - Composite channel weighting (matches backend ImportanceSignals)
|
||||
* - 4-axis radar vertex geometry (Novelty top / Arousal right / Reward
|
||||
* bottom / Attention left)
|
||||
* - Value clamping at the helper boundary (defensive against a mis-
|
||||
* scaled /api/importance response)
|
||||
* - Size-preset mapping (sm 80 / md 180 / lg 320)
|
||||
* - Trending-memory importance proxy (retention × log(reviews) / √age)
|
||||
* including the age=0 division-by-zero edge case.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
clamp01,
|
||||
clampChannels,
|
||||
compositeScore,
|
||||
CHANNEL_WEIGHTS,
|
||||
sizePreset,
|
||||
radarRadius,
|
||||
radarVertices,
|
||||
verticesToPath,
|
||||
importanceProxy,
|
||||
rankByProxy,
|
||||
AXIS_ORDER,
|
||||
SIZE_PX,
|
||||
type ProxyMemoryLike,
|
||||
} from '../importance-helpers';
|
||||
|
||||
// ===========================================================================
|
||||
// clamp01
|
||||
// ===========================================================================
|
||||
|
||||
describe('clamp01', () => {
|
||||
it('passes in-range values through', () => {
|
||||
expect(clamp01(0)).toBe(0);
|
||||
expect(clamp01(0.5)).toBe(0.5);
|
||||
expect(clamp01(1)).toBe(1);
|
||||
});
|
||||
|
||||
it('clamps below zero to 0', () => {
|
||||
expect(clamp01(-0.3)).toBe(0);
|
||||
expect(clamp01(-100)).toBe(0);
|
||||
});
|
||||
|
||||
it('clamps above one to 1', () => {
|
||||
expect(clamp01(1.0001)).toBe(1);
|
||||
expect(clamp01(42)).toBe(1);
|
||||
});
|
||||
|
||||
it('folds null / undefined / NaN / Infinity to 0', () => {
|
||||
expect(clamp01(null)).toBe(0);
|
||||
expect(clamp01(undefined)).toBe(0);
|
||||
expect(clamp01(NaN)).toBe(0);
|
||||
expect(clamp01(Infinity)).toBe(0);
|
||||
expect(clamp01(-Infinity)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clampChannels', () => {
|
||||
it('clamps every channel independently', () => {
|
||||
expect(clampChannels({ novelty: 2, arousal: -1, reward: 0.5, attention: NaN })).toEqual({
|
||||
novelty: 1,
|
||||
arousal: 0,
|
||||
reward: 0.5,
|
||||
attention: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('fills missing channels with 0', () => {
|
||||
expect(clampChannels({ novelty: 0.8 })).toEqual({
|
||||
novelty: 0.8,
|
||||
arousal: 0,
|
||||
reward: 0,
|
||||
attention: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts null / undefined as "all zeros"', () => {
|
||||
expect(clampChannels(null)).toEqual({ novelty: 0, arousal: 0, reward: 0, attention: 0 });
|
||||
expect(clampChannels(undefined)).toEqual({
|
||||
novelty: 0,
|
||||
arousal: 0,
|
||||
reward: 0,
|
||||
attention: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// compositeScore — MUST match backend ImportanceSignals weights
|
||||
// ===========================================================================
|
||||
|
||||
describe('compositeScore', () => {
|
||||
it('sums channel contributions with the documented weights', () => {
|
||||
const c = { novelty: 1, arousal: 1, reward: 1, attention: 1 };
|
||||
// 0.25 + 0.30 + 0.25 + 0.20 = 1.00
|
||||
expect(compositeScore(c)).toBeCloseTo(1.0, 5);
|
||||
});
|
||||
|
||||
it('is zero for all-zero channels', () => {
|
||||
expect(compositeScore({ novelty: 0, arousal: 0, reward: 0, attention: 0 })).toBe(0);
|
||||
});
|
||||
|
||||
it('weights match CHANNEL_WEIGHTS exactly (backend contract)', () => {
|
||||
expect(CHANNEL_WEIGHTS).toEqual({
|
||||
novelty: 0.25,
|
||||
arousal: 0.3,
|
||||
reward: 0.25,
|
||||
attention: 0.2,
|
||||
});
|
||||
// Weights sum to 1 — any drift here and the "composite ∈ [0,1]"
|
||||
// invariant falls over.
|
||||
const sum =
|
||||
CHANNEL_WEIGHTS.novelty +
|
||||
CHANNEL_WEIGHTS.arousal +
|
||||
CHANNEL_WEIGHTS.reward +
|
||||
CHANNEL_WEIGHTS.attention;
|
||||
expect(sum).toBeCloseTo(1.0, 10);
|
||||
});
|
||||
|
||||
it('matches the exact weighted formula per channel', () => {
|
||||
// 0.4·0.25 + 0.6·0.30 + 0.2·0.25 + 0.8·0.20
|
||||
// = 0.10 + 0.18 + 0.05 + 0.16 = 0.49
|
||||
expect(
|
||||
compositeScore({ novelty: 0.4, arousal: 0.6, reward: 0.2, attention: 0.8 }),
|
||||
).toBeCloseTo(0.49, 5);
|
||||
});
|
||||
|
||||
it('clamps inputs before weighting (never escapes [0,1])', () => {
|
||||
// All over-max → should pin to 1, not to 2.
|
||||
expect(
|
||||
compositeScore({ novelty: 2, arousal: 2, reward: 2, attention: 2 }),
|
||||
).toBeCloseTo(1.0, 5);
|
||||
// Negative channels count as 0.
|
||||
expect(
|
||||
compositeScore({ novelty: -1, arousal: -1, reward: -1, attention: -1 }),
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// Size preset
|
||||
// ===========================================================================
|
||||
|
||||
describe('sizePreset', () => {
|
||||
it('maps the three documented presets', () => {
|
||||
expect(sizePreset('sm')).toBe(80);
|
||||
expect(sizePreset('md')).toBe(180);
|
||||
expect(sizePreset('lg')).toBe(320);
|
||||
});
|
||||
|
||||
it('exposes the SIZE_PX mapping for external consumers', () => {
|
||||
expect(SIZE_PX).toEqual({ sm: 80, md: 180, lg: 320 });
|
||||
});
|
||||
|
||||
it('falls back to md (180) for unknown / missing keys', () => {
|
||||
expect(sizePreset(undefined)).toBe(180);
|
||||
expect(sizePreset('' as unknown as 'md')).toBe(180);
|
||||
expect(sizePreset('xl' as unknown as 'md')).toBe(180);
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// radarRadius — component padding rules
|
||||
// ===========================================================================
|
||||
|
||||
describe('radarRadius', () => {
|
||||
it('applies the correct padding per preset', () => {
|
||||
// sm: 80/2 - 4 = 36
|
||||
// md: 180/2 - 28 = 62
|
||||
// lg: 320/2 - 44 = 116
|
||||
expect(radarRadius('sm')).toBe(36);
|
||||
expect(radarRadius('md')).toBe(62);
|
||||
expect(radarRadius('lg')).toBe(116);
|
||||
});
|
||||
|
||||
it('never returns a negative radius', () => {
|
||||
// Can't construct a sub-zero radius via normal presets, but the
|
||||
// helper floors at 0 defensively.
|
||||
expect(radarRadius('md')).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// radarVertices — 4 SVG polygon points on the fixed axis order
|
||||
// ===========================================================================
|
||||
|
||||
describe('radarVertices', () => {
|
||||
it('emits vertices in Novelty→Arousal→Reward→Attention order', () => {
|
||||
expect(AXIS_ORDER.map((a) => a.key)).toEqual([
|
||||
'novelty',
|
||||
'arousal',
|
||||
'reward',
|
||||
'attention',
|
||||
]);
|
||||
});
|
||||
|
||||
it('places a 0-valued channel at the centre', () => {
|
||||
// Centre for md is (90, 90). novelty=0 means the top vertex sits AT
|
||||
// the centre — the polygon pinches inward.
|
||||
const v = radarVertices(
|
||||
{ novelty: 0, arousal: 0, reward: 0, attention: 0 },
|
||||
'md',
|
||||
);
|
||||
expect(v).toHaveLength(4);
|
||||
for (const p of v) {
|
||||
expect(p.x).toBeCloseTo(90, 5);
|
||||
expect(p.y).toBeCloseTo(90, 5);
|
||||
}
|
||||
});
|
||||
|
||||
it('places a 1-valued channel on the correct axis edge', () => {
|
||||
// Size md: cx=cy=90, r=62.
|
||||
// Novelty (angle -π/2, top) → (90, 90 - 62) = (90, 28)
|
||||
// Arousal (angle 0, right) → (90 + 62, 90) = (152, 90)
|
||||
// Reward (angle π/2, bottom) → (90, 90 + 62) = (90, 152)
|
||||
// Attention (angle π, left) → (90 - 62, 90) = (28, 90)
|
||||
const v = radarVertices(
|
||||
{ novelty: 1, arousal: 1, reward: 1, attention: 1 },
|
||||
'md',
|
||||
);
|
||||
expect(v[0].x).toBeCloseTo(90, 5);
|
||||
expect(v[0].y).toBeCloseTo(28, 5);
|
||||
|
||||
expect(v[1].x).toBeCloseTo(152, 5);
|
||||
expect(v[1].y).toBeCloseTo(90, 5);
|
||||
|
||||
expect(v[2].x).toBeCloseTo(90, 5);
|
||||
expect(v[2].y).toBeCloseTo(152, 5);
|
||||
|
||||
expect(v[3].x).toBeCloseTo(28, 5);
|
||||
expect(v[3].y).toBeCloseTo(90, 5);
|
||||
});
|
||||
|
||||
it('scales vertex radial distance linearly with the channel value', () => {
|
||||
// Arousal at 0.5 should land half-way from centre to the right edge.
|
||||
const v = radarVertices(
|
||||
{ novelty: 0, arousal: 0.5, reward: 0, attention: 0 },
|
||||
'md',
|
||||
);
|
||||
// radius=62, so right vertex x = 90 + 62*0.5 = 121.
|
||||
expect(v[1].x).toBeCloseTo(121, 5);
|
||||
expect(v[1].y).toBeCloseTo(90, 5);
|
||||
});
|
||||
|
||||
it('clamps out-of-range inputs rather than exiting the SVG box', () => {
|
||||
// novelty=2 should pin to the edge (not overshoot to 90 - 124 = -34).
|
||||
const v = radarVertices(
|
||||
{ novelty: 2, arousal: -0.5, reward: NaN, attention: Infinity },
|
||||
'md',
|
||||
);
|
||||
// Novelty pinned to edge (y=28), arousal/reward/attention at 0 land at centre.
|
||||
expect(v[0].y).toBeCloseTo(28, 5);
|
||||
expect(v[1].x).toBeCloseTo(90, 5); // arousal=0 → centre
|
||||
expect(v[2].y).toBeCloseTo(90, 5); // reward=0 → centre
|
||||
expect(v[3].x).toBeCloseTo(90, 5); // attention=0 → centre
|
||||
});
|
||||
|
||||
it('respects the active size preset', () => {
|
||||
// At sm (80px), radius=36. Novelty=1 → (40, 40-36) = (40, 4).
|
||||
const v = radarVertices({ novelty: 1, arousal: 0, reward: 0, attention: 0 }, 'sm');
|
||||
expect(v[0].x).toBeCloseTo(40, 5);
|
||||
expect(v[0].y).toBeCloseTo(4, 5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verticesToPath', () => {
|
||||
it('serialises to an SVG path with M/L commands and Z close', () => {
|
||||
const path = verticesToPath([
|
||||
{ x: 10, y: 20 },
|
||||
{ x: 30, y: 40 },
|
||||
{ x: 50, y: 60 },
|
||||
{ x: 70, y: 80 },
|
||||
]);
|
||||
expect(path).toBe('M10.00,20.00 L30.00,40.00 L50.00,60.00 L70.00,80.00 Z');
|
||||
});
|
||||
|
||||
it('returns an empty string for no points', () => {
|
||||
expect(verticesToPath([])).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// importanceProxy — "Top Important Memories This Week" ranking formula
|
||||
// ===========================================================================
|
||||
|
||||
describe('importanceProxy', () => {
|
||||
// Anchor everything to a fixed "now" so recency math is deterministic.
|
||||
const NOW = new Date('2026-04-20T12:00:00Z').getTime();
|
||||
|
||||
function mem(over: Partial<ProxyMemoryLike>): ProxyMemoryLike {
|
||||
return {
|
||||
retentionStrength: 0.5,
|
||||
reviewCount: 0,
|
||||
createdAt: new Date(NOW - 2 * 86_400_000).toISOString(),
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
it('is zero for zero retention', () => {
|
||||
expect(importanceProxy(mem({ retentionStrength: 0 }), NOW)).toBe(0);
|
||||
});
|
||||
|
||||
it('treats missing reviewCount as 0 (not a crash)', () => {
|
||||
const m = mem({ reviewCount: undefined, retentionStrength: 0.8 });
|
||||
const v = importanceProxy(m, NOW);
|
||||
expect(v).toBeGreaterThan(0);
|
||||
expect(Number.isFinite(v)).toBe(true);
|
||||
});
|
||||
|
||||
it('matches the documented formula: retention × log1p(reviews+1) / √age', () => {
|
||||
// createdAt = 4 days before NOW → ageDays = 4, √4 = 2.
|
||||
// retention = 0.6, reviews = 3 → log1p(4) ≈ 1.6094
|
||||
// expected = 0.6 × 1.6094 / 2 ≈ 0.4828
|
||||
const m = mem({
|
||||
retentionStrength: 0.6,
|
||||
reviewCount: 3,
|
||||
createdAt: new Date(NOW - 4 * 86_400_000).toISOString(),
|
||||
});
|
||||
const v = importanceProxy(m, NOW);
|
||||
const expected = (0.6 * Math.log1p(4)) / 2;
|
||||
expect(v).toBeCloseTo(expected, 6);
|
||||
});
|
||||
|
||||
it('clamps age to 1 day for a memory created RIGHT NOW (div-by-zero guard)', () => {
|
||||
// createdAt equals NOW → raw ageDays = 0. Without the clamp, the
|
||||
// recency boost would divide by zero. We assert the helper returns
|
||||
// a finite value equal to the "age=1" path.
|
||||
const zeroAge = importanceProxy(
|
||||
mem({
|
||||
retentionStrength: 0.5,
|
||||
reviewCount: 0,
|
||||
createdAt: new Date(NOW).toISOString(),
|
||||
}),
|
||||
NOW,
|
||||
);
|
||||
const oneDayAge = importanceProxy(
|
||||
mem({
|
||||
retentionStrength: 0.5,
|
||||
reviewCount: 0,
|
||||
createdAt: new Date(NOW - 1 * 86_400_000).toISOString(),
|
||||
}),
|
||||
NOW,
|
||||
);
|
||||
expect(Number.isFinite(zeroAge)).toBe(true);
|
||||
expect(zeroAge).toBeCloseTo(oneDayAge, 10);
|
||||
});
|
||||
|
||||
it('also clamps future-dated memories to ageDays=1 rather than going negative', () => {
|
||||
const future = importanceProxy(
|
||||
mem({
|
||||
retentionStrength: 0.5,
|
||||
reviewCount: 0,
|
||||
createdAt: new Date(NOW + 7 * 86_400_000).toISOString(),
|
||||
}),
|
||||
NOW,
|
||||
);
|
||||
expect(Number.isFinite(future)).toBe(true);
|
||||
expect(future).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('returns 0 for a malformed createdAt', () => {
|
||||
const m = {
|
||||
retentionStrength: 0.8,
|
||||
reviewCount: 3,
|
||||
createdAt: 'not-a-date',
|
||||
};
|
||||
expect(importanceProxy(m, NOW)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 0 when retentionStrength is non-finite', () => {
|
||||
expect(importanceProxy(mem({ retentionStrength: NaN }), NOW)).toBe(0);
|
||||
expect(importanceProxy(mem({ retentionStrength: Infinity }), NOW)).toBe(0);
|
||||
});
|
||||
|
||||
it('ranks recent + high-retention memories ahead of stale ones', () => {
|
||||
const fresh: ProxyMemoryLike = {
|
||||
retentionStrength: 0.9,
|
||||
reviewCount: 5,
|
||||
createdAt: new Date(NOW - 1 * 86_400_000).toISOString(),
|
||||
};
|
||||
const stale: ProxyMemoryLike = {
|
||||
retentionStrength: 0.9,
|
||||
reviewCount: 5,
|
||||
createdAt: new Date(NOW - 100 * 86_400_000).toISOString(),
|
||||
};
|
||||
expect(importanceProxy(fresh, NOW)).toBeGreaterThan(importanceProxy(stale, NOW));
|
||||
});
|
||||
});
|
||||
|
||||
describe('rankByProxy', () => {
|
||||
const NOW = new Date('2026-04-20T12:00:00Z').getTime();
|
||||
|
||||
it('sorts descending by the proxy score', () => {
|
||||
const items: (ProxyMemoryLike & { id: string })[] = [
|
||||
{ id: 'stale', retentionStrength: 0.9, reviewCount: 5, createdAt: new Date(NOW - 100 * 86_400_000).toISOString() },
|
||||
{ id: 'fresh', retentionStrength: 0.9, reviewCount: 5, createdAt: new Date(NOW - 1 * 86_400_000).toISOString() },
|
||||
{ id: 'dead', retentionStrength: 0.0, reviewCount: 0, createdAt: new Date(NOW - 2 * 86_400_000).toISOString() },
|
||||
];
|
||||
const ranked = rankByProxy(items, NOW);
|
||||
expect(ranked.map((r) => r.id)).toEqual(['fresh', 'stale', 'dead']);
|
||||
});
|
||||
|
||||
it('does not mutate the input array', () => {
|
||||
const items: ProxyMemoryLike[] = [
|
||||
{ retentionStrength: 0.1, reviewCount: 0, createdAt: new Date(NOW - 10 * 86_400_000).toISOString() },
|
||||
{ retentionStrength: 0.9, reviewCount: 9, createdAt: new Date(NOW - 1 * 86_400_000).toISOString() },
|
||||
];
|
||||
const before = items.slice();
|
||||
rankByProxy(items, NOW);
|
||||
expect(items).toEqual(before);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,298 @@
|
|||
/**
|
||||
* MemoryAuditTrail — pure helper coverage.
|
||||
*
|
||||
* Runs in vitest's Node environment (no jsdom). Every assertion exercises
|
||||
* a function in `audit-trail-helpers.ts` with fully deterministic inputs.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import {
|
||||
ALL_ACTIONS,
|
||||
META,
|
||||
VISIBLE_LIMIT,
|
||||
formatRetentionDelta,
|
||||
generateMockAuditTrail,
|
||||
hashSeed,
|
||||
makeRand,
|
||||
relativeTime,
|
||||
splitVisible,
|
||||
type AuditAction,
|
||||
type AuditEvent
|
||||
} from '../audit-trail-helpers';
|
||||
|
||||
// Fixed reference point for all time-based tests. Millisecond precision so
|
||||
// relative-time maths are exact, not drifting with wallclock time.
|
||||
const NOW = Date.UTC(2026, 3, 20, 12, 0, 0); // 2026-04-20 12:00:00 UTC
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// hashSeed + makeRand
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('hashSeed', () => {
|
||||
it('is deterministic', () => {
|
||||
expect(hashSeed('abc')).toBe(hashSeed('abc'));
|
||||
expect(hashSeed('memory-42')).toBe(hashSeed('memory-42'));
|
||||
});
|
||||
|
||||
it('different ids hash to different seeds', () => {
|
||||
expect(hashSeed('a')).not.toBe(hashSeed('b'));
|
||||
expect(hashSeed('memory-1')).not.toBe(hashSeed('memory-2'));
|
||||
});
|
||||
|
||||
it('empty string hashes to 0', () => {
|
||||
expect(hashSeed('')).toBe(0);
|
||||
});
|
||||
|
||||
it('returns an unsigned 32-bit integer', () => {
|
||||
// Stress: a long id should never produce a negative or non-integer seed.
|
||||
const seed = hashSeed('a'.repeat(256));
|
||||
expect(Number.isInteger(seed)).toBe(true);
|
||||
expect(seed).toBeGreaterThanOrEqual(0);
|
||||
expect(seed).toBeLessThan(2 ** 32);
|
||||
});
|
||||
});
|
||||
|
||||
describe('makeRand', () => {
|
||||
it('is deterministic given the same seed', () => {
|
||||
const a = makeRand(42);
|
||||
const b = makeRand(42);
|
||||
for (let i = 0; i < 20; i++) expect(a()).toBe(b());
|
||||
});
|
||||
|
||||
it('produces values strictly in [0, 1)', () => {
|
||||
// Seed with UINT32_MAX to force the edge case that exposed the original
|
||||
// `/ 0xffffffff` bug — the divisor must be 2^32, not 2^32 - 1.
|
||||
const rand = makeRand(0xffffffff);
|
||||
for (let i = 0; i < 5000; i++) {
|
||||
const v = rand();
|
||||
expect(v).toBeGreaterThanOrEqual(0);
|
||||
expect(v).toBeLessThan(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('different seeds produce different sequences', () => {
|
||||
const a = makeRand(1);
|
||||
const b = makeRand(2);
|
||||
expect(a()).not.toBe(b());
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Deterministic generator
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('generateMockAuditTrail — determinism', () => {
|
||||
it('same id + same now always yields the same sequence', () => {
|
||||
const a = generateMockAuditTrail('memory-xyz', NOW);
|
||||
const b = generateMockAuditTrail('memory-xyz', NOW);
|
||||
expect(a).toEqual(b);
|
||||
});
|
||||
|
||||
it('different ids yield different sequences', () => {
|
||||
const a = generateMockAuditTrail('memory-a', NOW);
|
||||
const b = generateMockAuditTrail('memory-b', NOW);
|
||||
// Either different lengths or different event-by-event — anything but equal.
|
||||
expect(a).not.toEqual(b);
|
||||
});
|
||||
|
||||
it('empty id yields no events — the panel should never fabricate history', () => {
|
||||
expect(generateMockAuditTrail('', NOW)).toEqual([]);
|
||||
});
|
||||
|
||||
it('count fits the default 8-15 range', () => {
|
||||
// Sample a handful of ids — the distribution should stay in range.
|
||||
for (const id of ['a', 'abc', 'memory-1', 'memory-2', 'memory-3', 'x'.repeat(50)]) {
|
||||
const events = generateMockAuditTrail(id, NOW);
|
||||
expect(events.length).toBeGreaterThanOrEqual(8);
|
||||
expect(events.length).toBeLessThanOrEqual(15);
|
||||
}
|
||||
});
|
||||
|
||||
it('first emitted event (newest-first order → last in array) is "created"', () => {
|
||||
const events = generateMockAuditTrail('deterministic-id', NOW);
|
||||
expect(events[events.length - 1].action).toBe('created');
|
||||
expect(events[events.length - 1].triggered_by).toBe('smart_ingest');
|
||||
});
|
||||
|
||||
it('emits events in newest-first order', () => {
|
||||
const events = generateMockAuditTrail('order-check', NOW);
|
||||
for (let i = 1; i < events.length; i++) {
|
||||
const prev = new Date(events[i - 1].timestamp).getTime();
|
||||
const curr = new Date(events[i].timestamp).getTime();
|
||||
expect(prev).toBeGreaterThanOrEqual(curr);
|
||||
}
|
||||
});
|
||||
|
||||
it('all timestamps are valid ISO strings in the past relative to NOW', () => {
|
||||
const events = generateMockAuditTrail('iso-check', NOW);
|
||||
for (const ev of events) {
|
||||
const t = new Date(ev.timestamp).getTime();
|
||||
expect(Number.isFinite(t)).toBe(true);
|
||||
expect(t).toBeLessThanOrEqual(NOW);
|
||||
}
|
||||
});
|
||||
|
||||
it('respects countOverride — 16 events crosses the visibility threshold', () => {
|
||||
const events = generateMockAuditTrail('big', NOW, 16);
|
||||
expect(events).toHaveLength(16);
|
||||
});
|
||||
|
||||
it('retention values never escape [0, 1]', () => {
|
||||
for (const id of ['x', 'y', 'z', 'memory-big']) {
|
||||
const events = generateMockAuditTrail(id, NOW, 30);
|
||||
for (const ev of events) {
|
||||
if (ev.old_value !== undefined) {
|
||||
expect(ev.old_value).toBeGreaterThanOrEqual(0);
|
||||
expect(ev.old_value).toBeLessThanOrEqual(1);
|
||||
}
|
||||
if (ev.new_value !== undefined) {
|
||||
expect(ev.new_value).toBeGreaterThanOrEqual(0);
|
||||
expect(ev.new_value).toBeLessThanOrEqual(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Relative time
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('relativeTime — boundary cases', () => {
|
||||
// Build an ISO timestamp `offsetMs` before NOW.
|
||||
const ago = (offsetMs: number) => new Date(NOW - offsetMs).toISOString();
|
||||
|
||||
const cases: Array<[string, number, string]> = [
|
||||
['0s ago', 0, '0s ago'],
|
||||
['59s ago', 59 * 1000, '59s ago'],
|
||||
['60s flips to 1m', 60 * 1000, '1m ago'],
|
||||
['59m ago', 59 * 60 * 1000, '59m ago'],
|
||||
['60m flips to 1h', 60 * 60 * 1000, '1h ago'],
|
||||
['23h ago', 23 * 3600 * 1000, '23h ago'],
|
||||
['24h flips to 1d', 24 * 3600 * 1000, '1d ago'],
|
||||
['6d ago', 6 * 86400 * 1000, '6d ago'],
|
||||
['7d ago', 7 * 86400 * 1000, '7d ago'],
|
||||
['29d ago', 29 * 86400 * 1000, '29d ago'],
|
||||
['30d flips to 1mo', 30 * 86400 * 1000, '1mo ago'],
|
||||
['365d → 12mo flips to 1y', 365 * 86400 * 1000, '1y ago']
|
||||
];
|
||||
|
||||
for (const [name, offset, expected] of cases) {
|
||||
it(name, () => {
|
||||
expect(relativeTime(ago(offset), NOW)).toBe(expected);
|
||||
});
|
||||
}
|
||||
|
||||
it('future timestamps clamp to "0s ago"', () => {
|
||||
const future = new Date(NOW + 60_000).toISOString();
|
||||
expect(relativeTime(future, NOW)).toBe('0s ago');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Event type → marker mapping
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('META — action to marker mapping', () => {
|
||||
it('covers all 8 audit actions exactly', () => {
|
||||
expect(Object.keys(META).sort()).toEqual([...ALL_ACTIONS].sort());
|
||||
expect(ALL_ACTIONS).toHaveLength(8);
|
||||
});
|
||||
|
||||
it('every action has a distinct marker kind (8 kinds → 8 glyph shapes)', () => {
|
||||
const kinds = ALL_ACTIONS.map((a) => META[a].kind);
|
||||
expect(new Set(kinds).size).toBe(8);
|
||||
});
|
||||
|
||||
it('every action has a non-empty label and hex color', () => {
|
||||
for (const action of ALL_ACTIONS) {
|
||||
const m = META[action];
|
||||
expect(m.label.length).toBeGreaterThan(0);
|
||||
expect(m.color).toMatch(/^#[0-9a-f]{6}$/i);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Retention delta formatter
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('formatRetentionDelta', () => {
|
||||
it('returns null when both values are missing', () => {
|
||||
expect(formatRetentionDelta(undefined, undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns "set X.XX" when only new is defined', () => {
|
||||
expect(formatRetentionDelta(undefined, 0.5)).toBe('set 0.50');
|
||||
// Note: toFixed(2) uses float-to-string half-to-even; assert on values
|
||||
// that round unambiguously rather than on IEEE-754 tie edges.
|
||||
expect(formatRetentionDelta(undefined, 0.736)).toBe('set 0.74');
|
||||
});
|
||||
|
||||
it('returns "was X.XX" when only old is defined', () => {
|
||||
expect(formatRetentionDelta(0.5, undefined)).toBe('was 0.50');
|
||||
});
|
||||
|
||||
it('returns "old → new" when both are defined', () => {
|
||||
expect(formatRetentionDelta(0.5, 0.7)).toBe('0.50 → 0.70');
|
||||
expect(formatRetentionDelta(0.72, 0.85)).toBe('0.72 → 0.85');
|
||||
});
|
||||
|
||||
it('handles descending deltas without changing the arrow', () => {
|
||||
// Suppression / demotion paths — old > new.
|
||||
expect(formatRetentionDelta(0.8, 0.6)).toBe('0.80 → 0.60');
|
||||
});
|
||||
|
||||
it('rejects non-finite numbers', () => {
|
||||
expect(formatRetentionDelta(NaN, 0.5)).toBe('set 0.50');
|
||||
expect(formatRetentionDelta(0.5, NaN)).toBe('was 0.50');
|
||||
expect(formatRetentionDelta(NaN, NaN)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// splitVisible — 15-event cap
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('splitVisible — collapse threshold', () => {
|
||||
const makeEvents = (n: number): AuditEvent[] =>
|
||||
Array.from({ length: n }, (_, i) => ({
|
||||
action: 'accessed' as AuditAction,
|
||||
timestamp: new Date(NOW - i * 60_000).toISOString()
|
||||
}));
|
||||
|
||||
it('VISIBLE_LIMIT is 15', () => {
|
||||
expect(VISIBLE_LIMIT).toBe(15);
|
||||
});
|
||||
|
||||
it('exactly 15 events → no toggle (hiddenCount 0)', () => {
|
||||
const { visible, hiddenCount } = splitVisible(makeEvents(15), false);
|
||||
expect(visible).toHaveLength(15);
|
||||
expect(hiddenCount).toBe(0);
|
||||
});
|
||||
|
||||
it('14 events → no toggle', () => {
|
||||
const { visible, hiddenCount } = splitVisible(makeEvents(14), false);
|
||||
expect(visible).toHaveLength(14);
|
||||
expect(hiddenCount).toBe(0);
|
||||
});
|
||||
|
||||
it('16 events collapsed → visible 15, hidden 1', () => {
|
||||
const { visible, hiddenCount } = splitVisible(makeEvents(16), false);
|
||||
expect(visible).toHaveLength(15);
|
||||
expect(hiddenCount).toBe(1);
|
||||
});
|
||||
|
||||
it('16 events expanded → visible 16, hidden reports overflow count (1)', () => {
|
||||
const { visible, hiddenCount } = splitVisible(makeEvents(16), true);
|
||||
expect(visible).toHaveLength(16);
|
||||
expect(hiddenCount).toBe(1);
|
||||
});
|
||||
|
||||
it('0 events → visible empty, hidden 0', () => {
|
||||
const { visible, hiddenCount } = splitVisible(makeEvents(0), false);
|
||||
expect(visible).toHaveLength(0);
|
||||
expect(hiddenCount).toBe(0);
|
||||
});
|
||||
|
||||
it('preserves newest-first order when truncating', () => {
|
||||
const events = makeEvents(20);
|
||||
const { visible } = splitVisible(events, false);
|
||||
expect(visible[0]).toBe(events[0]);
|
||||
expect(visible[14]).toBe(events[14]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,334 @@
|
|||
/**
|
||||
* Unit tests for patterns-helpers — the pure logic backing
|
||||
* PatternTransferHeatmap.svelte + patterns/+page.svelte.
|
||||
*
|
||||
* Runs in the vitest `node` environment (no jsdom). We never touch Svelte
|
||||
* component internals here — only the exported helpers in patterns-helpers.ts.
|
||||
* Component-level integration (click, hover, DOM wiring) is covered by the
|
||||
* Playwright e2e suite; this file is pure-logic coverage of the contracts.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
cellIntensity,
|
||||
filterByCategory,
|
||||
buildTransferMatrix,
|
||||
matrixMaxCount,
|
||||
flattenNonZero,
|
||||
shortProjectName,
|
||||
PATTERN_CATEGORIES,
|
||||
type TransferPatternLike,
|
||||
} from '../patterns-helpers';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test fixtures — mirror the mockFetchCrossProject shape in
|
||||
// patterns/+page.svelte, but small enough to reason about by hand.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PROJECTS = ['vestige', 'nullgaze', 'injeranet'] as const;
|
||||
|
||||
const PATTERNS: TransferPatternLike[] = [
|
||||
{
|
||||
name: 'Result<T, E>',
|
||||
category: 'ErrorHandling',
|
||||
origin_project: 'vestige',
|
||||
transferred_to: ['nullgaze', 'injeranet'],
|
||||
transfer_count: 2,
|
||||
},
|
||||
{
|
||||
name: 'Axum middleware',
|
||||
category: 'ErrorHandling',
|
||||
origin_project: 'nullgaze',
|
||||
transferred_to: ['vestige'],
|
||||
transfer_count: 1,
|
||||
},
|
||||
{
|
||||
name: 'proptest',
|
||||
category: 'Testing',
|
||||
origin_project: 'vestige',
|
||||
transferred_to: ['nullgaze'],
|
||||
transfer_count: 1,
|
||||
},
|
||||
{
|
||||
name: 'Self-reuse pattern',
|
||||
category: 'Architecture',
|
||||
origin_project: 'vestige',
|
||||
transferred_to: ['vestige'], // diagonal — self-reuse
|
||||
transfer_count: 1,
|
||||
},
|
||||
];
|
||||
|
||||
// ===========================================================================
|
||||
// cellIntensity — 0..1 opacity normaliser
|
||||
// ===========================================================================
|
||||
|
||||
describe('cellIntensity', () => {
|
||||
it('returns 0 for a zero count', () => {
|
||||
expect(cellIntensity(0, 10)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 1 at max', () => {
|
||||
expect(cellIntensity(10, 10)).toBe(1);
|
||||
});
|
||||
|
||||
it('returns 1 when count exceeds max (defensive clamp)', () => {
|
||||
expect(cellIntensity(15, 10)).toBe(1);
|
||||
});
|
||||
|
||||
it('scales linearly between 0 and max', () => {
|
||||
expect(cellIntensity(3, 10)).toBeCloseTo(0.3, 5);
|
||||
expect(cellIntensity(5, 10)).toBeCloseTo(0.5, 5);
|
||||
expect(cellIntensity(7, 10)).toBeCloseTo(0.7, 5);
|
||||
});
|
||||
|
||||
it('returns 0 when max is 0 (div-by-zero guard)', () => {
|
||||
expect(cellIntensity(5, 0)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 0 for negative counts', () => {
|
||||
expect(cellIntensity(-1, 10)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 0 for NaN inputs', () => {
|
||||
expect(cellIntensity(NaN, 10)).toBe(0);
|
||||
expect(cellIntensity(5, NaN)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 0 for Infinity inputs', () => {
|
||||
expect(cellIntensity(Infinity, 10)).toBe(0);
|
||||
expect(cellIntensity(5, Infinity)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// filterByCategory — drives both heatmap + sidebar reflow
|
||||
// ===========================================================================
|
||||
|
||||
describe('filterByCategory', () => {
|
||||
it("returns every pattern for 'All'", () => {
|
||||
const out = filterByCategory(PATTERNS, 'All');
|
||||
expect(out).toHaveLength(PATTERNS.length);
|
||||
// Should NOT return the same reference — helpers return a copy so
|
||||
// callers can mutate freely.
|
||||
expect(out).not.toBe(PATTERNS);
|
||||
});
|
||||
|
||||
it('filters strictly by category equality', () => {
|
||||
const errorOnly = filterByCategory(PATTERNS, 'ErrorHandling');
|
||||
expect(errorOnly).toHaveLength(2);
|
||||
expect(errorOnly.every((p) => p.category === 'ErrorHandling')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns exactly one match for Testing', () => {
|
||||
const testing = filterByCategory(PATTERNS, 'Testing');
|
||||
expect(testing).toHaveLength(1);
|
||||
expect(testing[0].name).toBe('proptest');
|
||||
});
|
||||
|
||||
it('returns an empty array for a category with no patterns', () => {
|
||||
const perf = filterByCategory(PATTERNS, 'Performance');
|
||||
expect(perf).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns an empty array for an unknown category string (no silent alias)', () => {
|
||||
// This is the "unknown category fallback" contract — we do NOT
|
||||
// quietly fall back to 'All'. An unknown category is a caller bug
|
||||
// and yields an empty list so the empty-state UI renders.
|
||||
expect(filterByCategory(PATTERNS, 'NotARealCategory')).toEqual([]);
|
||||
expect(filterByCategory(PATTERNS, '')).toEqual([]);
|
||||
});
|
||||
|
||||
it('accepts an empty input array for any category', () => {
|
||||
expect(filterByCategory([], 'All')).toEqual([]);
|
||||
expect(filterByCategory([], 'ErrorHandling')).toEqual([]);
|
||||
expect(filterByCategory([], 'BogusCategory')).toEqual([]);
|
||||
});
|
||||
|
||||
it('exposes all six supported categories', () => {
|
||||
expect([...PATTERN_CATEGORIES]).toEqual([
|
||||
'ErrorHandling',
|
||||
'AsyncConcurrency',
|
||||
'Testing',
|
||||
'Architecture',
|
||||
'Performance',
|
||||
'Security',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// buildTransferMatrix — directional N×N projects × projects grid
|
||||
// ===========================================================================
|
||||
|
||||
describe('buildTransferMatrix', () => {
|
||||
it('constructs an N×N matrix over the projects axis', () => {
|
||||
const m = buildTransferMatrix(PROJECTS, []);
|
||||
for (const from of PROJECTS) {
|
||||
for (const to of PROJECTS) {
|
||||
expect(m[from][to]).toEqual({ count: 0, topNames: [] });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('aggregates transfer counts directionally', () => {
|
||||
const m = buildTransferMatrix(PROJECTS, PATTERNS);
|
||||
// vestige → nullgaze: Result<T,E> + proptest = 2
|
||||
expect(m.vestige.nullgaze.count).toBe(2);
|
||||
// vestige → injeranet: Result<T,E> only = 1
|
||||
expect(m.vestige.injeranet.count).toBe(1);
|
||||
// nullgaze → vestige: Axum middleware = 1
|
||||
expect(m.nullgaze.vestige.count).toBe(1);
|
||||
// injeranet → anywhere: zero (no origin in injeranet in fixtures)
|
||||
expect(m.injeranet.vestige.count).toBe(0);
|
||||
expect(m.injeranet.nullgaze.count).toBe(0);
|
||||
});
|
||||
|
||||
it('treats (A, B) and (B, A) as distinct directions (asymmetry confirmed)', () => {
|
||||
// The component's doc-comment says "Rows = origin project · Columns =
|
||||
// destination project" — the matrix MUST be directional. A copy-paste
|
||||
// bug that aggregates both directions into the same cell would pass
|
||||
// the "count" test above but fail this symmetry check.
|
||||
const m = buildTransferMatrix(PROJECTS, PATTERNS);
|
||||
expect(m.vestige.nullgaze.count).not.toBe(m.nullgaze.vestige.count);
|
||||
});
|
||||
|
||||
it('records self-transfer on the diagonal', () => {
|
||||
const m = buildTransferMatrix(PROJECTS, PATTERNS);
|
||||
expect(m.vestige.vestige.count).toBe(1);
|
||||
expect(m.vestige.vestige.topNames).toEqual(['Self-reuse pattern']);
|
||||
});
|
||||
|
||||
it('captures top pattern names per cell, capped at 3', () => {
|
||||
const manyPatterns: TransferPatternLike[] = Array.from({ length: 5 }, (_, i) => ({
|
||||
name: `pattern-${i}`,
|
||||
category: 'ErrorHandling',
|
||||
origin_project: 'vestige',
|
||||
transferred_to: ['nullgaze'],
|
||||
transfer_count: 1,
|
||||
}));
|
||||
const m = buildTransferMatrix(['vestige', 'nullgaze'], manyPatterns);
|
||||
expect(m.vestige.nullgaze.count).toBe(5);
|
||||
expect(m.vestige.nullgaze.topNames).toHaveLength(3);
|
||||
expect(m.vestige.nullgaze.topNames).toEqual(['pattern-0', 'pattern-1', 'pattern-2']);
|
||||
});
|
||||
|
||||
it('silently drops patterns whose origin is not in the projects axis', () => {
|
||||
const orphan: TransferPatternLike = {
|
||||
name: 'Orphan',
|
||||
category: 'Security',
|
||||
origin_project: 'ghost-project',
|
||||
transferred_to: ['vestige'],
|
||||
transfer_count: 1,
|
||||
};
|
||||
const m = buildTransferMatrix(PROJECTS, [orphan]);
|
||||
// Nothing anywhere in the matrix should have ticked up.
|
||||
const total = matrixMaxCount(PROJECTS, m);
|
||||
expect(total).toBe(0);
|
||||
// Matrix structure intact — no ghost key added.
|
||||
expect((m as Record<string, unknown>)['ghost-project']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('silently drops transferred_to entries not in the projects axis', () => {
|
||||
const strayDest: TransferPatternLike = {
|
||||
name: 'StrayDest',
|
||||
category: 'Security',
|
||||
origin_project: 'vestige',
|
||||
transferred_to: ['ghost-project', 'nullgaze'],
|
||||
transfer_count: 2,
|
||||
};
|
||||
const m = buildTransferMatrix(PROJECTS, [strayDest]);
|
||||
// The known destination counts; the ghost doesn't.
|
||||
expect(m.vestige.nullgaze.count).toBe(1);
|
||||
expect((m.vestige as Record<string, unknown>)['ghost-project']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('respects a custom top-name cap', () => {
|
||||
const pats: TransferPatternLike[] = [
|
||||
{
|
||||
name: 'a',
|
||||
category: 'Testing',
|
||||
origin_project: 'vestige',
|
||||
transferred_to: ['nullgaze'],
|
||||
transfer_count: 1,
|
||||
},
|
||||
{
|
||||
name: 'b',
|
||||
category: 'Testing',
|
||||
origin_project: 'vestige',
|
||||
transferred_to: ['nullgaze'],
|
||||
transfer_count: 1,
|
||||
},
|
||||
];
|
||||
const m = buildTransferMatrix(['vestige', 'nullgaze'], pats, 1);
|
||||
expect(m.vestige.nullgaze.topNames).toEqual(['a']);
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// matrixMaxCount
|
||||
// ===========================================================================
|
||||
|
||||
describe('matrixMaxCount', () => {
|
||||
it('returns 0 for an empty matrix (div-by-zero guard prerequisite)', () => {
|
||||
const m = buildTransferMatrix(PROJECTS, []);
|
||||
expect(matrixMaxCount(PROJECTS, m)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns the hottest cell count across all pairs', () => {
|
||||
const m = buildTransferMatrix(PROJECTS, PATTERNS);
|
||||
// vestige→nullgaze has 2; everything else is ≤1
|
||||
expect(matrixMaxCount(PROJECTS, m)).toBe(2);
|
||||
});
|
||||
|
||||
it('tolerates missing rows without crashing', () => {
|
||||
const partial: Record<string, Record<string, { count: number; topNames: string[] }>> = {
|
||||
vestige: { vestige: { count: 3, topNames: [] } },
|
||||
};
|
||||
expect(matrixMaxCount(['vestige', 'absent'], partial)).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// flattenNonZero — mobile fallback feed
|
||||
// ===========================================================================
|
||||
|
||||
describe('flattenNonZero', () => {
|
||||
it('returns only non-zero pairs, sorted by count descending', () => {
|
||||
const m = buildTransferMatrix(PROJECTS, PATTERNS);
|
||||
const rows = flattenNonZero(PROJECTS, m);
|
||||
// Distinct non-zero cells in fixtures:
|
||||
// vestige→nullgaze = 2
|
||||
// vestige→injeranet = 1
|
||||
// vestige→vestige = 1
|
||||
// nullgaze→vestige = 1
|
||||
expect(rows).toHaveLength(4);
|
||||
expect(rows[0]).toMatchObject({ from: 'vestige', to: 'nullgaze', count: 2 });
|
||||
// Later rows all tied at 1 — we only verify the leader.
|
||||
expect(rows.slice(1).every((r) => r.count === 1)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns an empty list when nothing is transferred', () => {
|
||||
const m = buildTransferMatrix(PROJECTS, []);
|
||||
expect(flattenNonZero(PROJECTS, m)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// shortProjectName
|
||||
// ===========================================================================
|
||||
|
||||
describe('shortProjectName', () => {
|
||||
it('passes short names through unchanged', () => {
|
||||
expect(shortProjectName('vestige')).toBe('vestige');
|
||||
expect(shortProjectName('')).toBe('');
|
||||
});
|
||||
|
||||
it('keeps names at the 12-char boundary', () => {
|
||||
expect(shortProjectName('123456789012')).toBe('123456789012');
|
||||
});
|
||||
|
||||
it('truncates longer names to 11 chars + ellipsis', () => {
|
||||
expect(shortProjectName('1234567890123')).toBe('12345678901…');
|
||||
expect(shortProjectName('super-long-project-name')).toBe('super-long-…');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,193 @@
|
|||
/**
|
||||
* ReasoningChain — pure-logic coverage.
|
||||
*
|
||||
* ReasoningChain renders the 8-stage cognitive pipeline. Its rendered output
|
||||
* is a pure function of a handful of primitive props — confidence colours,
|
||||
* intent-hint selection, and the stage hint resolver. All of that logic
|
||||
* lives in `reasoning-helpers.ts` and is exercised here without mounting
|
||||
* Svelte.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import {
|
||||
confidenceColor,
|
||||
confidenceLabel,
|
||||
intentHintFor,
|
||||
INTENT_HINTS,
|
||||
CONFIDENCE_EMERALD,
|
||||
CONFIDENCE_AMBER,
|
||||
CONFIDENCE_RED,
|
||||
type IntentKey,
|
||||
} from '../reasoning-helpers';
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// confidenceColor — the spec-critical boundary table
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('confidenceColor — band boundaries (>75 emerald, 40-75 amber, <40 red)', () => {
|
||||
it.each<[number, string]>([
|
||||
// Emerald band: strictly greater than 75
|
||||
[100, CONFIDENCE_EMERALD],
|
||||
[99.99, CONFIDENCE_EMERALD],
|
||||
[80, CONFIDENCE_EMERALD],
|
||||
[76, CONFIDENCE_EMERALD],
|
||||
[75.01, CONFIDENCE_EMERALD],
|
||||
// Amber band: 40 <= c <= 75
|
||||
[75, CONFIDENCE_AMBER], // exactly 75 → amber (page spec: `>75` emerald)
|
||||
[60, CONFIDENCE_AMBER],
|
||||
[50, CONFIDENCE_AMBER],
|
||||
[40.01, CONFIDENCE_AMBER],
|
||||
[40, CONFIDENCE_AMBER], // exactly 40 → amber (page spec: `>=40` amber)
|
||||
// Red band: strictly less than 40
|
||||
[39.99, CONFIDENCE_RED],
|
||||
[20, CONFIDENCE_RED],
|
||||
[0.01, CONFIDENCE_RED],
|
||||
[0, CONFIDENCE_RED],
|
||||
])('confidence %f → %s', (c, expected) => {
|
||||
expect(confidenceColor(c)).toBe(expected);
|
||||
});
|
||||
|
||||
it('clamps negative to red (defensive — confidence should never be negative)', () => {
|
||||
expect(confidenceColor(-10)).toBe(CONFIDENCE_RED);
|
||||
});
|
||||
|
||||
it('over-100 stays emerald (defensive — confidence should never exceed 100)', () => {
|
||||
expect(confidenceColor(150)).toBe(CONFIDENCE_EMERALD);
|
||||
});
|
||||
|
||||
it('NaN → red (worst-case band)', () => {
|
||||
expect(confidenceColor(Number.NaN)).toBe(CONFIDENCE_RED);
|
||||
});
|
||||
|
||||
it('is pure — same input yields same output', () => {
|
||||
for (const c of [0, 39.99, 40, 75, 75.01, 100]) {
|
||||
expect(confidenceColor(c)).toBe(confidenceColor(c));
|
||||
}
|
||||
});
|
||||
|
||||
it('never returns an empty string or undefined', () => {
|
||||
for (const c of [-1, 0, 20, 40, 75, 76, 100, 200, Number.NaN]) {
|
||||
const colour = confidenceColor(c);
|
||||
expect(typeof colour).toBe('string');
|
||||
expect(colour.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('confidenceLabel — human text per band', () => {
|
||||
it.each<[number, string]>([
|
||||
[100, 'HIGH CONFIDENCE'],
|
||||
[76, 'HIGH CONFIDENCE'],
|
||||
[75.01, 'HIGH CONFIDENCE'],
|
||||
[75, 'MIXED SIGNAL'],
|
||||
[60, 'MIXED SIGNAL'],
|
||||
[40, 'MIXED SIGNAL'],
|
||||
[39.99, 'LOW CONFIDENCE'],
|
||||
[0, 'LOW CONFIDENCE'],
|
||||
])('confidence %f → %s', (c, expected) => {
|
||||
expect(confidenceLabel(c)).toBe(expected);
|
||||
});
|
||||
|
||||
it('NaN → LOW CONFIDENCE (safe default)', () => {
|
||||
expect(confidenceLabel(Number.NaN)).toBe('LOW CONFIDENCE');
|
||||
});
|
||||
|
||||
it('agrees with confidenceColor across the spec boundary sweep', () => {
|
||||
// Sanity: if the label is HIGH, the colour must be emerald, etc.
|
||||
const cases: Array<[number, string, string]> = [
|
||||
[100, 'HIGH CONFIDENCE', CONFIDENCE_EMERALD],
|
||||
[76, 'HIGH CONFIDENCE', CONFIDENCE_EMERALD],
|
||||
[75, 'MIXED SIGNAL', CONFIDENCE_AMBER],
|
||||
[40, 'MIXED SIGNAL', CONFIDENCE_AMBER],
|
||||
[39.99, 'LOW CONFIDENCE', CONFIDENCE_RED],
|
||||
[0, 'LOW CONFIDENCE', CONFIDENCE_RED],
|
||||
];
|
||||
for (const [c, label, colour] of cases) {
|
||||
expect(confidenceLabel(c)).toBe(label);
|
||||
expect(confidenceColor(c)).toBe(colour);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Intent classification — visual hint mapping
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('INTENT_HINTS — one hint per deep_reference intent', () => {
|
||||
const intents: IntentKey[] = [
|
||||
'FactCheck',
|
||||
'Timeline',
|
||||
'RootCause',
|
||||
'Comparison',
|
||||
'Synthesis',
|
||||
];
|
||||
|
||||
it('defines a hint for every intent the backend emits', () => {
|
||||
for (const i of intents) {
|
||||
expect(INTENT_HINTS[i]).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(intents)('%s hint has label + icon + description', (i) => {
|
||||
const hint = INTENT_HINTS[i];
|
||||
expect(hint.label).toBe(i); // label doubles as canonical id
|
||||
expect(hint.icon.length).toBeGreaterThan(0);
|
||||
expect(hint.description.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('icons are unique across intents (so the eye can distinguish them)', () => {
|
||||
const icons = intents.map((i) => INTENT_HINTS[i].icon);
|
||||
expect(new Set(icons).size).toBe(intents.length);
|
||||
});
|
||||
|
||||
it('descriptions are distinct across intents', () => {
|
||||
const descs = intents.map((i) => INTENT_HINTS[i].description);
|
||||
expect(new Set(descs).size).toBe(intents.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('intentHintFor — lookup with safe fallback', () => {
|
||||
it('returns the exact entry for a known intent', () => {
|
||||
expect(intentHintFor('FactCheck')).toBe(INTENT_HINTS.FactCheck);
|
||||
expect(intentHintFor('Timeline')).toBe(INTENT_HINTS.Timeline);
|
||||
expect(intentHintFor('RootCause')).toBe(INTENT_HINTS.RootCause);
|
||||
expect(intentHintFor('Comparison')).toBe(INTENT_HINTS.Comparison);
|
||||
expect(intentHintFor('Synthesis')).toBe(INTENT_HINTS.Synthesis);
|
||||
});
|
||||
|
||||
it('falls back to Synthesis for unknown intent (most generic classification)', () => {
|
||||
expect(intentHintFor('Prediction')).toBe(INTENT_HINTS.Synthesis);
|
||||
expect(intentHintFor('nonsense')).toBe(INTENT_HINTS.Synthesis);
|
||||
});
|
||||
|
||||
it('falls back to Synthesis for null / undefined / empty string', () => {
|
||||
expect(intentHintFor(null)).toBe(INTENT_HINTS.Synthesis);
|
||||
expect(intentHintFor(undefined)).toBe(INTENT_HINTS.Synthesis);
|
||||
expect(intentHintFor('')).toBe(INTENT_HINTS.Synthesis);
|
||||
});
|
||||
|
||||
it('is case-sensitive — backend emits Title-case strings and we honour that', () => {
|
||||
// If case-folding becomes desirable, this test will force the
|
||||
// change to be explicit rather than accidental.
|
||||
expect(intentHintFor('factcheck')).toBe(INTENT_HINTS.Synthesis);
|
||||
expect(intentHintFor('FACTCHECK')).toBe(INTENT_HINTS.Synthesis);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Stage-count invariant — the component renders exactly 8 stages
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Cognitive pipeline shape', () => {
|
||||
it('confidence colour constants are all distinct hex strings', () => {
|
||||
const set = new Set([
|
||||
CONFIDENCE_EMERALD.toLowerCase(),
|
||||
CONFIDENCE_AMBER.toLowerCase(),
|
||||
CONFIDENCE_RED.toLowerCase(),
|
||||
]);
|
||||
expect(set.size).toBe(3);
|
||||
for (const c of set) {
|
||||
expect(c).toMatch(/^#[0-9a-f]{6}$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
237
apps/dashboard/src/lib/components/activation-helpers.ts
Normal file
237
apps/dashboard/src/lib/components/activation-helpers.ts
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
/**
|
||||
* activation-helpers — Pure logic for the Spreading Activation Live View.
|
||||
*
|
||||
* Extracted from ActivationNetwork.svelte + activation/+page.svelte so the
|
||||
* decay / geometry / event-filtering rules can be exercised in the Vitest
|
||||
* `node` environment without jsdom. Every helper in this module is a pure
|
||||
* function of its inputs; no DOM, no timers, no Svelte runes.
|
||||
*
|
||||
* The constants in this module are the single source of truth — the Svelte
|
||||
* component re-exports / re-uses them rather than hard-coding its own.
|
||||
*
|
||||
* References
|
||||
* ----------
|
||||
* - Collins & Loftus 1975 — spreading activation with exponential decay
|
||||
* - Anderson 1983 (ACT-R) — activation threshold for availability
|
||||
*/
|
||||
import { NODE_TYPE_COLORS } from '$types';
|
||||
import type { VestigeEvent } from '$types';
|
||||
|
||||
/** Per-tick multiplicative decay factor (Collins & Loftus 1975). */
|
||||
export const DECAY = 0.93;
|
||||
|
||||
/** Activation below this floor is invisible / garbage-collected. */
|
||||
export const MIN_VISIBLE = 0.05;
|
||||
|
||||
/** Fallback node colour when NODE_TYPE_COLORS has no entry for the type. */
|
||||
export const FALLBACK_COLOR = '#8B95A5';
|
||||
|
||||
/** Source node colour (synapse-glow). Distinct from any node-type colour. */
|
||||
export const SOURCE_COLOR = '#818cf8';
|
||||
|
||||
/** Radial spacing between concentric rings (px). */
|
||||
export const RING_GAP = 140;
|
||||
|
||||
/** Max neighbours that fit on ring 1 before spilling to ring 2. */
|
||||
export const RING_1_CAPACITY = 8;
|
||||
|
||||
/** Edge draw stagger — frames of delay per rank inside a ring. */
|
||||
export const STAGGER_PER_RANK = 4;
|
||||
|
||||
/** Extra stagger added to ring-2 edges so they light up after ring 1. */
|
||||
export const STAGGER_RING_2_BONUS = 12;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Decay math
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Apply a single tick of exponential decay. Clamps negative input to 0 so a
|
||||
* corrupt state never produces a creeping-positive value on the next tick.
|
||||
*/
|
||||
export function applyDecay(activation: number): number {
|
||||
if (!Number.isFinite(activation) || activation <= 0) return 0;
|
||||
return activation * DECAY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compound decay over N ticks. N < 0 is treated as 0 (no change).
|
||||
* Equivalent to calling `applyDecay` N times.
|
||||
*/
|
||||
export function compoundDecay(activation: number, ticks: number): number {
|
||||
if (!Number.isFinite(activation) || activation <= 0) return 0;
|
||||
if (!Number.isFinite(ticks) || ticks <= 0) return activation;
|
||||
return activation * DECAY ** ticks;
|
||||
}
|
||||
|
||||
/** True if the node's activation is at or above the visibility floor. */
|
||||
export function isVisible(activation: number): boolean {
|
||||
if (!Number.isFinite(activation)) return false;
|
||||
return activation >= MIN_VISIBLE;
|
||||
}
|
||||
|
||||
/**
|
||||
* How many ticks until `initial` decays below `MIN_VISIBLE`. Useful in tests
|
||||
* and for sizing animation budgets. Initial <= threshold returns 0.
|
||||
*/
|
||||
export function ticksUntilInvisible(initial: number): number {
|
||||
if (!Number.isFinite(initial) || initial <= MIN_VISIBLE) return 0;
|
||||
// initial * DECAY^n < MIN_VISIBLE → n > log(MIN_VISIBLE/initial) / log(DECAY)
|
||||
const n = Math.log(MIN_VISIBLE / initial) / Math.log(DECAY);
|
||||
return Math.ceil(n);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ring placement — concentric circles around a source
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a neighbour's 0-indexed rank into a ring number.
|
||||
* Ranks 0..RING_1_CAPACITY-1 → ring 1; rest → ring 2.
|
||||
*/
|
||||
export function computeRing(rank: number): 1 | 2 {
|
||||
if (!Number.isFinite(rank) || rank < RING_1_CAPACITY) return 1;
|
||||
return 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evenly distribute `count` positions on a circle of radius `ring * RING_GAP`
|
||||
* centred at (cx, cy). `angleOffset` rotates the whole ring so overlapping
|
||||
* bursts don't perfectly collide. Zero count returns `[]`.
|
||||
*/
|
||||
export function ringPositions(
|
||||
cx: number,
|
||||
cy: number,
|
||||
count: number,
|
||||
ring: number,
|
||||
angleOffset = 0,
|
||||
): Point[] {
|
||||
if (!Number.isFinite(count) || count <= 0) return [];
|
||||
const radius = RING_GAP * ring;
|
||||
const positions: Point[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const angle = angleOffset + (i / count) * Math.PI * 2;
|
||||
positions.push({
|
||||
x: cx + Math.cos(angle) * radius,
|
||||
y: cy + Math.sin(angle) * radius,
|
||||
});
|
||||
}
|
||||
return positions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given the full neighbour list, produce a flat array of Points — ring 1
|
||||
* first, ring 2 after. The resulting length === neighbours.length.
|
||||
*/
|
||||
export function layoutNeighbours(
|
||||
cx: number,
|
||||
cy: number,
|
||||
neighbourCount: number,
|
||||
angleOffset = 0,
|
||||
): Point[] {
|
||||
const ring1 = Math.min(neighbourCount, RING_1_CAPACITY);
|
||||
const ring2 = Math.max(0, neighbourCount - RING_1_CAPACITY);
|
||||
return [
|
||||
...ringPositions(cx, cy, ring1, 1, angleOffset),
|
||||
...ringPositions(cx, cy, ring2, 2, angleOffset),
|
||||
];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Initial activation by rank
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Seed activation for a neighbour at 0-indexed `rank` given `total`.
|
||||
* Higher-ranked (earlier) neighbours get stronger initial activation.
|
||||
* Ring-2 neighbours get a 0.75× ring-factor penalty on top of the rank factor.
|
||||
* Returns a value in (0, 1].
|
||||
*/
|
||||
export function initialActivation(rank: number, total: number): number {
|
||||
if (!Number.isFinite(total) || total <= 0) return 0;
|
||||
if (!Number.isFinite(rank) || rank < 0) return 0;
|
||||
const rankFactor = 1 - (rank / total) * 0.35;
|
||||
const ringFactor = computeRing(rank) === 1 ? 1 : 0.75;
|
||||
return Math.min(1, rankFactor * ringFactor);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edge stagger
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Delay (in animation frames) before the edge at rank `i` starts drawing.
|
||||
* Ring 1 edges light up first, then ring 2 after a bonus delay.
|
||||
*/
|
||||
export function edgeStagger(rank: number): number {
|
||||
if (!Number.isFinite(rank) || rank < 0) return 0;
|
||||
const r = Math.floor(rank);
|
||||
const base = r * STAGGER_PER_RANK;
|
||||
return computeRing(r) === 1 ? base : base + STAGGER_RING_2_BONUS;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Color mapping
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Colour for a node on the activation canvas.
|
||||
* - source nodes always use SOURCE_COLOR (synapse-glow)
|
||||
* - known node types use NODE_TYPE_COLORS
|
||||
* - unknown node types fall back to FALLBACK_COLOR (soft steel)
|
||||
*/
|
||||
export function activationColor(
|
||||
nodeType: string | null | undefined,
|
||||
isSource: boolean,
|
||||
): string {
|
||||
if (isSource) return SOURCE_COLOR;
|
||||
if (!nodeType) return FALLBACK_COLOR;
|
||||
return NODE_TYPE_COLORS[nodeType] ?? FALLBACK_COLOR;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Event-feed filtering — "only fire on NEW ActivationSpread events"
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SpreadPayload {
|
||||
source_id: string;
|
||||
target_ids: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract ActivationSpread payloads from a websocket event feed. The feed
|
||||
* is prepended (newest at index 0, oldest at the end). Stop as soon as we
|
||||
* hit the reference of `lastSeen` — events at or past that point were
|
||||
* already processed by a prior tick.
|
||||
*
|
||||
* Returned payloads are in OLDEST-FIRST order so downstream callers can
|
||||
* fire them in the same narrative order they occurred.
|
||||
*
|
||||
* Payloads missing required fields are silently skipped.
|
||||
*/
|
||||
export function filterNewSpreadEvents(
|
||||
feed: readonly VestigeEvent[],
|
||||
lastSeen: VestigeEvent | null,
|
||||
): SpreadPayload[] {
|
||||
if (!feed || feed.length === 0) return [];
|
||||
const fresh: SpreadPayload[] = [];
|
||||
for (const ev of feed) {
|
||||
if (ev === lastSeen) break;
|
||||
if (ev.type !== 'ActivationSpread') continue;
|
||||
const data = ev.data as { source_id?: unknown; target_ids?: unknown };
|
||||
if (typeof data.source_id !== 'string') continue;
|
||||
if (!Array.isArray(data.target_ids)) continue;
|
||||
const targets = data.target_ids.filter(
|
||||
(t): t is string => typeof t === 'string',
|
||||
);
|
||||
if (targets.length === 0) continue;
|
||||
fresh.push({ source_id: data.source_id, target_ids: targets });
|
||||
}
|
||||
// Reverse so oldest-first.
|
||||
return fresh.reverse();
|
||||
}
|
||||
293
apps/dashboard/src/lib/components/audit-trail-helpers.ts
Normal file
293
apps/dashboard/src/lib/components/audit-trail-helpers.ts
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
/**
|
||||
* Pure helpers for MemoryAuditTrail.
|
||||
*
|
||||
* Extracted for isolated unit testing in a Node (vitest) environment —
|
||||
* no DOM, no Svelte runtime, no fetch. Every function in this module is
|
||||
* deterministic given its inputs.
|
||||
*/
|
||||
|
||||
export type AuditAction =
|
||||
| 'created'
|
||||
| 'accessed'
|
||||
| 'promoted'
|
||||
| 'demoted'
|
||||
| 'edited'
|
||||
| 'suppressed'
|
||||
| 'dreamed'
|
||||
| 'reconsolidated';
|
||||
|
||||
export interface AuditEvent {
|
||||
action: AuditAction;
|
||||
timestamp: string; // ISO
|
||||
old_value?: number;
|
||||
new_value?: number;
|
||||
reason?: string;
|
||||
triggered_by?: string;
|
||||
}
|
||||
|
||||
export type MarkerKind =
|
||||
| 'dot'
|
||||
| 'arrow-up'
|
||||
| 'arrow-down'
|
||||
| 'pencil'
|
||||
| 'x'
|
||||
| 'star'
|
||||
| 'circle-arrow'
|
||||
| 'ring';
|
||||
|
||||
export interface Meta {
|
||||
label: string;
|
||||
color: string; // hex for dot + glow
|
||||
glyph: string; // optional inline symbol
|
||||
kind: MarkerKind;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event type → visual metadata. Each action maps to a UNIQUE marker `kind`
|
||||
* so the 8 event types are visually distinguishable without relying on the
|
||||
* colour palette alone (accessibility).
|
||||
*/
|
||||
export const META: Record<AuditAction, Meta> = {
|
||||
created: { label: 'Created', color: '#10b981', glyph: '', kind: 'ring' },
|
||||
accessed: { label: 'Accessed', color: '#3b82f6', glyph: '', kind: 'dot' },
|
||||
promoted: { label: 'Promoted', color: '#10b981', glyph: '', kind: 'arrow-up' },
|
||||
demoted: { label: 'Demoted', color: '#f59e0b', glyph: '', kind: 'arrow-down' },
|
||||
edited: { label: 'Edited', color: '#facc15', glyph: '', kind: 'pencil' },
|
||||
suppressed: { label: 'Suppressed', color: '#a855f7', glyph: '', kind: 'x' },
|
||||
dreamed: { label: 'Dreamed', color: '#c084fc', glyph: '', kind: 'star' },
|
||||
reconsolidated: { label: 'Reconsolidated', color: '#ec4899', glyph: '', kind: 'circle-arrow' }
|
||||
};
|
||||
|
||||
export const VISIBLE_LIMIT = 15;
|
||||
|
||||
/**
|
||||
* All 8 `AuditAction` values, in the canonical order. Used both by the
|
||||
* event generator (`actionPool`) and by tests that verify uniqueness of
|
||||
* the marker mapping.
|
||||
*/
|
||||
export const ALL_ACTIONS: readonly AuditAction[] = [
|
||||
'created',
|
||||
'accessed',
|
||||
'promoted',
|
||||
'demoted',
|
||||
'edited',
|
||||
'suppressed',
|
||||
'dreamed',
|
||||
'reconsolidated'
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Hash a string id into a 32-bit unsigned seed. Stable across runs.
|
||||
*/
|
||||
export function hashSeed(id: string): number {
|
||||
let seed = 0;
|
||||
for (let i = 0; i < id.length; i++) seed = (seed * 31 + id.charCodeAt(i)) >>> 0;
|
||||
return seed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Linear congruential PRNG bound to a mutable seed. Returns a function
|
||||
* that yields floats in `[0, 1)` — critically, NEVER 1.0, so callers
|
||||
* can safely use `Math.floor(rand() * arr.length)` without off-by-one.
|
||||
*/
|
||||
export function makeRand(initialSeed: number): () => number {
|
||||
let seed = initialSeed >>> 0;
|
||||
return () => {
|
||||
seed = (seed * 1664525 + 1013904223) >>> 0;
|
||||
// Divide by 2^32, not 2^32 - 1 — the latter can yield exactly 1.0
|
||||
// when seed is UINT32_MAX, breaking array-index math.
|
||||
return seed / 0x100000000;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic mock audit-trail generator. Same `memoryId` + `nowMs`
|
||||
* ALWAYS yields the same event sequence (critical for snapshot stability
|
||||
* and for tests). An empty `memoryId` yields no events — the audit trail
|
||||
* panel should never invent history for a non-existent memory.
|
||||
*
|
||||
* `countOverride` lets tests force a specific number of events (e.g.
|
||||
* to cross the 15-event visibility threshold, which the default range
|
||||
* 8-15 cannot do).
|
||||
*/
|
||||
export function generateMockAuditTrail(
|
||||
memoryId: string,
|
||||
nowMs: number = Date.now(),
|
||||
countOverride?: number
|
||||
): AuditEvent[] {
|
||||
if (!memoryId) return [];
|
||||
|
||||
const rand = makeRand(hashSeed(memoryId));
|
||||
const count = countOverride ?? 8 + Math.floor(rand() * 8); // default 8-15 events
|
||||
if (count <= 0) return [];
|
||||
|
||||
const out: AuditEvent[] = [];
|
||||
|
||||
const createdAt = nowMs - (14 + rand() * 21) * 86_400_000; // 14-35 days ago
|
||||
out.push({
|
||||
action: 'created',
|
||||
timestamp: new Date(createdAt).toISOString(),
|
||||
reason: 'smart_ingest · prediction-error gate opened',
|
||||
triggered_by: 'smart_ingest'
|
||||
});
|
||||
|
||||
let t = createdAt;
|
||||
let retention = 0.5 + rand() * 0.2;
|
||||
const actionPool: AuditAction[] = [
|
||||
'accessed',
|
||||
'accessed',
|
||||
'accessed',
|
||||
'accessed',
|
||||
'promoted',
|
||||
'demoted',
|
||||
'edited',
|
||||
'dreamed',
|
||||
'reconsolidated',
|
||||
'suppressed'
|
||||
];
|
||||
|
||||
for (let i = 1; i < count; i++) {
|
||||
t += rand() * 5 * 86_400_000 + 3_600_000; // 1h-5d between events
|
||||
const action = actionPool[Math.floor(rand() * actionPool.length)];
|
||||
const ev: AuditEvent = { action, timestamp: new Date(t).toISOString() };
|
||||
|
||||
switch (action) {
|
||||
case 'accessed': {
|
||||
const old = retention;
|
||||
retention = Math.min(1, retention + rand() * 0.04 + 0.01);
|
||||
ev.old_value = old;
|
||||
ev.new_value = retention;
|
||||
ev.triggered_by = rand() > 0.5 ? 'search' : 'deep_reference';
|
||||
break;
|
||||
}
|
||||
case 'promoted': {
|
||||
const old = retention;
|
||||
retention = Math.min(1, retention + 0.1);
|
||||
ev.old_value = old;
|
||||
ev.new_value = retention;
|
||||
ev.reason = 'confirmed helpful by user';
|
||||
ev.triggered_by = 'memory(action=promote)';
|
||||
break;
|
||||
}
|
||||
case 'demoted': {
|
||||
const old = retention;
|
||||
retention = Math.max(0, retention - 0.15);
|
||||
ev.old_value = old;
|
||||
ev.new_value = retention;
|
||||
ev.reason = 'user flagged as outdated';
|
||||
ev.triggered_by = 'memory(action=demote)';
|
||||
break;
|
||||
}
|
||||
case 'edited': {
|
||||
ev.reason = 'content refined, FSRS state preserved';
|
||||
ev.triggered_by = 'memory(action=edit)';
|
||||
break;
|
||||
}
|
||||
case 'suppressed': {
|
||||
const old = retention;
|
||||
retention = Math.max(0, retention - 0.08);
|
||||
ev.old_value = old;
|
||||
ev.new_value = retention;
|
||||
ev.reason = 'top-down inhibition (Anderson 2025)';
|
||||
ev.triggered_by = 'suppress(dashboard)';
|
||||
break;
|
||||
}
|
||||
case 'dreamed': {
|
||||
const old = retention;
|
||||
retention = Math.min(1, retention + 0.05);
|
||||
ev.old_value = old;
|
||||
ev.new_value = retention;
|
||||
ev.reason = 'replayed during dream consolidation';
|
||||
ev.triggered_by = 'dream()';
|
||||
break;
|
||||
}
|
||||
case 'reconsolidated': {
|
||||
ev.reason = 'edited within 5-min labile window (Nader)';
|
||||
ev.triggered_by = 'reconsolidation-manager';
|
||||
break;
|
||||
}
|
||||
case 'created':
|
||||
// Created is only emitted once, as the first event. If the pool
|
||||
// ever yields it again, treat it as a no-op access marker with
|
||||
// no retention change — defensive, not expected.
|
||||
ev.triggered_by = 'smart_ingest';
|
||||
break;
|
||||
}
|
||||
|
||||
out.push(ev);
|
||||
}
|
||||
|
||||
// Newest first for display.
|
||||
return out.reverse();
|
||||
}
|
||||
|
||||
/**
|
||||
* Humanised relative time. Uses supplied `nowMs` for deterministic tests;
|
||||
* defaults to `Date.now()` in production.
|
||||
*
|
||||
* Boundaries (strictly `<`, so 60s flips to "1m", 60m flips to "1h", etc.):
|
||||
* <60s → "Ns ago"
|
||||
* <60m → "Nm ago"
|
||||
* <24h → "Nh ago"
|
||||
* <30d → "Nd ago"
|
||||
* <12mo → "Nmo ago"
|
||||
* else → "Ny ago"
|
||||
*
|
||||
* Future timestamps (nowMs < then) clamp to "0s ago" rather than returning
|
||||
* a negative string — the audit trail is a past-only view.
|
||||
*/
|
||||
export function relativeTime(iso: string, nowMs: number = Date.now()): string {
|
||||
const then = new Date(iso).getTime();
|
||||
const diff = Math.max(0, nowMs - then);
|
||||
const s = Math.floor(diff / 1000);
|
||||
if (s < 60) return `${s}s ago`;
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
const d = Math.floor(h / 24);
|
||||
if (d < 30) return `${d}d ago`;
|
||||
const mo = Math.floor(d / 30);
|
||||
if (mo < 12) return `${mo}mo ago`;
|
||||
const y = Math.floor(mo / 12);
|
||||
return `${y}y ago`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retention delta formatter. Behaviour:
|
||||
* (undef, undef) → null — no retention movement on this event
|
||||
* (undef, 0.72) → "set 0.72" — initial value, no prior state
|
||||
* (0.50, undef) → "was 0.50" — retention cleared (rare)
|
||||
* (0.50, 0.72) → "0.50 → 0.72"
|
||||
*
|
||||
* The `retention ` prefix is left to the caller so tests can compare the
|
||||
* core formatted value precisely.
|
||||
*/
|
||||
export function formatRetentionDelta(
|
||||
oldValue: number | undefined,
|
||||
newValue: number | undefined
|
||||
): string | null {
|
||||
const hasOld = typeof oldValue === 'number' && Number.isFinite(oldValue);
|
||||
const hasNew = typeof newValue === 'number' && Number.isFinite(newValue);
|
||||
if (!hasOld && !hasNew) return null;
|
||||
if (!hasOld && hasNew) return `set ${newValue!.toFixed(2)}`;
|
||||
if (hasOld && !hasNew) return `was ${oldValue!.toFixed(2)}`;
|
||||
return `${oldValue!.toFixed(2)} → ${newValue!.toFixed(2)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split an event list into (visible, hiddenCount) per the 15-event cap.
|
||||
* Exactly 15 events → no toggle (hiddenCount = 0). 16+ → toggle.
|
||||
*/
|
||||
export function splitVisible(
|
||||
events: AuditEvent[],
|
||||
showAll: boolean
|
||||
): { visible: AuditEvent[]; hiddenCount: number } {
|
||||
if (showAll || events.length <= VISIBLE_LIMIT) {
|
||||
return { visible: events, hiddenCount: Math.max(0, events.length - VISIBLE_LIMIT) };
|
||||
}
|
||||
return {
|
||||
visible: events.slice(0, VISIBLE_LIMIT),
|
||||
hiddenCount: events.length - VISIBLE_LIMIT
|
||||
};
|
||||
}
|
||||
192
apps/dashboard/src/lib/components/awareness-helpers.ts
Normal file
192
apps/dashboard/src/lib/components/awareness-helpers.ts
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
/**
|
||||
* Pure helpers for AmbientAwarenessStrip.svelte.
|
||||
*
|
||||
* Extracted so the time-window, event-scan, and timestamp-parsing logic can
|
||||
* be unit tested in the vitest `node` environment without jsdom, Svelte
|
||||
* rendering, or fake timers bleeding into runes.
|
||||
*
|
||||
* Contracts
|
||||
* ---------
|
||||
* - `parseEventTimestamp`: handles (a) numeric ms (>1e12), (b) numeric seconds
|
||||
* (<=1e12), (c) ISO-8601 string, (d) invalid/absent → null.
|
||||
* - `bucketizeActivity`: given ms timestamps + `now`, returns 10 counts for a
|
||||
* 5-min trailing window. Bucket 0 = oldest 30s, bucket 9 = newest 30s.
|
||||
* Events outside [now-5m, now] are dropped (clock skew).
|
||||
* - `findRecentDream`: returns the newest-indexed (feed is newest-first)
|
||||
* DreamCompleted whose parsed timestamp is within 24h, else null. If the
|
||||
* timestamp is unparseable, `now` is used as the fallback (matches the
|
||||
* component's behavior).
|
||||
* - `isDreaming`: a DreamStarted within the last 5 min NOT followed by a
|
||||
* newer DreamCompleted. Mirrors the component's derived block exactly.
|
||||
* - `hasRecentSuppression`: any MemorySuppressed event with a parsed
|
||||
* timestamp within `thresholdMs` of `now`. Feed is assumed newest-first —
|
||||
* we break as soon as we pass the threshold, matching component behavior.
|
||||
*
|
||||
* All helpers are null-safe and treat unparseable timestamps consistently
|
||||
* (fall back to `now`, matching the on-screen "something just happened" feel).
|
||||
*/
|
||||
|
||||
export interface EventLike {
|
||||
type: string;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a VestigeEvent timestamp, checking `data.timestamp`, then `data.at`,
|
||||
* then `data.occurred_at`. Supports ms-since-epoch numbers, seconds-since-epoch
|
||||
* numbers, and ISO-8601 strings. Returns null for absent / invalid input.
|
||||
*
|
||||
* Numeric heuristic: values > 1e12 are treated as ms (2001+), values <= 1e12
|
||||
* are treated as seconds. `1e12 ms` ≈ Sept 2001, so any real ms timestamp
|
||||
* lands safely on the "ms" side.
|
||||
*/
|
||||
export function parseEventTimestamp(event: EventLike): number | null {
|
||||
const d = event.data;
|
||||
if (!d || typeof d !== 'object') return null;
|
||||
const raw =
|
||||
(d.timestamp as string | number | undefined) ??
|
||||
(d.at as string | number | undefined) ??
|
||||
(d.occurred_at as string | number | undefined);
|
||||
if (raw === undefined || raw === null) return null;
|
||||
if (typeof raw === 'number') {
|
||||
if (!Number.isFinite(raw)) return null;
|
||||
return raw > 1e12 ? raw : raw * 1000;
|
||||
}
|
||||
if (typeof raw !== 'string') return null;
|
||||
const ms = Date.parse(raw);
|
||||
return Number.isFinite(ms) ? ms : null;
|
||||
}
|
||||
|
||||
export const ACTIVITY_BUCKET_COUNT = 10;
|
||||
export const ACTIVITY_BUCKET_MS = 30_000;
|
||||
export const ACTIVITY_WINDOW_MS = ACTIVITY_BUCKET_COUNT * ACTIVITY_BUCKET_MS;
|
||||
|
||||
export interface ActivityBucket {
|
||||
count: number;
|
||||
ratio: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bucket event timestamps into 10 × 30s buckets for a 5-min trailing window.
|
||||
* Events with `type === 'Heartbeat'` are skipped (noise). Events whose
|
||||
* timestamp is out of window (clock skew / pre-history) are dropped.
|
||||
*
|
||||
* Returned `ratio` is `count / max(1, maxBucketCount)` — so a sparkline with
|
||||
* zero events has all-zero ratios (no division by zero) and a sparkline with
|
||||
* a single spike peaks at 1.0.
|
||||
*/
|
||||
export function bucketizeActivity(
|
||||
events: EventLike[],
|
||||
nowMs: number,
|
||||
): ActivityBucket[] {
|
||||
const start = nowMs - ACTIVITY_WINDOW_MS;
|
||||
const counts = new Array<number>(ACTIVITY_BUCKET_COUNT).fill(0);
|
||||
for (const e of events) {
|
||||
if (e.type === 'Heartbeat') continue;
|
||||
const t = parseEventTimestamp(e);
|
||||
if (t === null || t < start || t > nowMs) continue;
|
||||
const idx = Math.min(
|
||||
ACTIVITY_BUCKET_COUNT - 1,
|
||||
Math.floor((t - start) / ACTIVITY_BUCKET_MS),
|
||||
);
|
||||
counts[idx] += 1;
|
||||
}
|
||||
const max = Math.max(1, ...counts);
|
||||
return counts.map((count) => ({ count, ratio: count / max }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the most recent DreamCompleted within 24h of `nowMs`.
|
||||
* Feed is assumed newest-first — we return the FIRST match.
|
||||
* Unparseable timestamps fall back to `nowMs` (matches component behavior).
|
||||
*/
|
||||
export function findRecentDream(
|
||||
events: EventLike[],
|
||||
nowMs: number,
|
||||
): EventLike | null {
|
||||
const dayAgo = nowMs - 24 * 60 * 60 * 1000;
|
||||
for (const e of events) {
|
||||
if (e.type !== 'DreamCompleted') continue;
|
||||
const t = parseEventTimestamp(e) ?? nowMs;
|
||||
if (t >= dayAgo) return e;
|
||||
return null; // newest-first: older ones definitely won't match
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract `insights_generated` / `insightsGenerated` from a DreamCompleted
|
||||
* event payload. Returns null if missing or non-numeric.
|
||||
*/
|
||||
export function dreamInsightsCount(event: EventLike | null): number | null {
|
||||
if (!event || !event.data) return null;
|
||||
const d = event.data;
|
||||
const raw =
|
||||
typeof d.insights_generated === 'number'
|
||||
? d.insights_generated
|
||||
: typeof d.insightsGenerated === 'number'
|
||||
? d.insightsGenerated
|
||||
: null;
|
||||
return raw !== null && Number.isFinite(raw) ? raw : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A Dream is in flight if the newest DreamStarted is within 5 min of `nowMs`
|
||||
* AND there is no DreamCompleted with a timestamp >= that DreamStarted.
|
||||
*
|
||||
* Feed is assumed newest-first. We scan once, grabbing the first Started and
|
||||
* first Completed, then compare — matching the component's derived block.
|
||||
*/
|
||||
export function isDreaming(events: EventLike[], nowMs: number): boolean {
|
||||
let started: EventLike | null = null;
|
||||
let completed: EventLike | null = null;
|
||||
for (const e of events) {
|
||||
if (!started && e.type === 'DreamStarted') started = e;
|
||||
if (!completed && e.type === 'DreamCompleted') completed = e;
|
||||
if (started && completed) break;
|
||||
}
|
||||
if (!started) return false;
|
||||
const startedAt = parseEventTimestamp(started) ?? nowMs;
|
||||
const fiveMinAgo = nowMs - 5 * 60 * 1000;
|
||||
if (startedAt < fiveMinAgo) return false;
|
||||
if (!completed) return true;
|
||||
const completedAt = parseEventTimestamp(completed) ?? nowMs;
|
||||
return completedAt < startedAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an "ago" duration compactly. Pure and deterministic.
|
||||
* 0-59s → "Ns ago", 60-3599s → "Nm ago", <24h → "Nh ago", else "Nd ago".
|
||||
* Negative input is clamped to 0.
|
||||
*/
|
||||
export function formatAgo(ms: number): string {
|
||||
const clamped = Math.max(0, ms);
|
||||
const s = Math.floor(clamped / 1000);
|
||||
if (s < 60) return `${s}s ago`;
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
return `${Math.floor(h / 24)}d ago`;
|
||||
}
|
||||
|
||||
/**
|
||||
* True if any MemorySuppressed event lies within `thresholdMs` of `nowMs`.
|
||||
* Feed assumed newest-first — break as soon as we encounter one OUTSIDE
|
||||
* the window (all older ones are definitely older). Unparseable timestamps
|
||||
* fall back to `nowMs` so the flash fires — matches component behavior.
|
||||
*/
|
||||
export function hasRecentSuppression(
|
||||
events: EventLike[],
|
||||
nowMs: number,
|
||||
thresholdMs: number = 10_000,
|
||||
): boolean {
|
||||
const cutoff = nowMs - thresholdMs;
|
||||
for (const e of events) {
|
||||
if (e.type !== 'MemorySuppressed') continue;
|
||||
const t = parseEventTimestamp(e) ?? nowMs;
|
||||
if (t >= cutoff) return true;
|
||||
return false; // newest-first: older ones definitely won't match
|
||||
}
|
||||
return false;
|
||||
}
|
||||
210
apps/dashboard/src/lib/components/contradiction-helpers.ts
Normal file
210
apps/dashboard/src/lib/components/contradiction-helpers.ts
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
/**
|
||||
* contradiction-helpers — Pure logic for the Contradiction Constellation UI.
|
||||
*
|
||||
* Extracted from ContradictionArcs.svelte + contradictions/+page.svelte so
|
||||
* the math and classification live in one place and can be tested in the
|
||||
* vitest `node` environment without jsdom / Svelte harnessing.
|
||||
*
|
||||
* Contracts
|
||||
* ---------
|
||||
* - Severity thresholds are STRICTLY exclusive: similarity > 0.7 → strong,
|
||||
* similarity > 0.5 → moderate, else → mild. The boundary values 0.5 and
|
||||
* 0.7 therefore fall into the LOWER band on purpose (so a similarity of
|
||||
* exactly 0.7 is 'moderate', not 'strong').
|
||||
* - Node type palette has 8 known types; anything else — including
|
||||
* `undefined`, `null`, empty string, or a typo — falls back to violet
|
||||
* (#8b5cf6), matching the `concept` fallback tone used elsewhere.
|
||||
* - Pair opacity is a trinary rule: no focus → 1, focused match → 1,
|
||||
* focused non-match → 0.12. `null` and `undefined` both mean "no focus".
|
||||
* - Trust is defined on [0,1]; `nodeRadius` clamps out-of-range values so
|
||||
* a negative trust can't produce a sub-zero radius and a >1 trust can't
|
||||
* balloon past the design maximum (14px).
|
||||
* - `uniqueMemoryCount` unions memory_a_id + memory_b_id across the whole
|
||||
* pair list; duplicated pairs do not double-count.
|
||||
*/
|
||||
|
||||
/** Shape used by the constellation. Mirrors ContradictionArcs.Contradiction. */
|
||||
export interface ContradictionLike {
|
||||
memory_a_id: string;
|
||||
memory_b_id: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Severity — similarity → colour + label.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type SeverityLabel = 'strong' | 'moderate' | 'mild';
|
||||
|
||||
/** Strong threshold. Similarity STRICTLY above this is red. */
|
||||
export const SEVERITY_STRONG_THRESHOLD = 0.7;
|
||||
/** Moderate threshold. Similarity STRICTLY above this (and <= 0.7) is amber. */
|
||||
export const SEVERITY_MODERATE_THRESHOLD = 0.5;
|
||||
|
||||
export const SEVERITY_STRONG_COLOR = '#ef4444';
|
||||
export const SEVERITY_MODERATE_COLOR = '#f59e0b';
|
||||
export const SEVERITY_MILD_COLOR = '#fde047';
|
||||
|
||||
/**
|
||||
* Severity colour by similarity. Boundaries at 0.5 and 0.7 fall into the
|
||||
* LOWER band (strictly-greater-than comparison).
|
||||
*
|
||||
* sim > 0.7 → '#ef4444' (strong / red)
|
||||
* sim > 0.5 → '#f59e0b' (moderate / amber)
|
||||
* otherwise → '#fde047' (mild / yellow)
|
||||
*/
|
||||
export function severityColor(sim: number): string {
|
||||
if (sim > SEVERITY_STRONG_THRESHOLD) return SEVERITY_STRONG_COLOR;
|
||||
if (sim > SEVERITY_MODERATE_THRESHOLD) return SEVERITY_MODERATE_COLOR;
|
||||
return SEVERITY_MILD_COLOR;
|
||||
}
|
||||
|
||||
/** Severity label by similarity. Same thresholds as severityColor. */
|
||||
export function severityLabel(sim: number): SeverityLabel {
|
||||
if (sim > SEVERITY_STRONG_THRESHOLD) return 'strong';
|
||||
if (sim > SEVERITY_MODERATE_THRESHOLD) return 'moderate';
|
||||
return 'mild';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Node type palette.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Fallback colour used when a memory's node_type is missing or unknown. */
|
||||
export const NODE_COLOR_FALLBACK = '#8b5cf6';
|
||||
|
||||
/** Canonical palette for the 8 known node types. */
|
||||
export const NODE_COLORS: Record<string, string> = {
|
||||
fact: '#3b82f6',
|
||||
concept: '#8b5cf6',
|
||||
event: '#f59e0b',
|
||||
person: '#10b981',
|
||||
place: '#06b6d4',
|
||||
note: '#6b7280',
|
||||
pattern: '#ec4899',
|
||||
decision: '#ef4444',
|
||||
};
|
||||
|
||||
/** Canonical list of known types (stable order — matches palette object). */
|
||||
export const KNOWN_NODE_TYPES = Object.freeze([
|
||||
'fact',
|
||||
'concept',
|
||||
'event',
|
||||
'person',
|
||||
'place',
|
||||
'note',
|
||||
'pattern',
|
||||
'decision',
|
||||
]) as readonly string[];
|
||||
|
||||
/**
|
||||
* Map a (possibly undefined) node_type to a colour. Unknown / missing /
|
||||
* empty / null strings fall back to violet (#8b5cf6).
|
||||
*/
|
||||
export function nodeColor(t?: string | null): string {
|
||||
if (!t) return NODE_COLOR_FALLBACK;
|
||||
return NODE_COLORS[t] ?? NODE_COLOR_FALLBACK;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Trust → node radius.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Minimum circle radius at trust=0. */
|
||||
export const NODE_RADIUS_MIN = 5;
|
||||
/** Additional radius at trust=1. `r = 5 + trust * 9`, so r ∈ [5, 14]. */
|
||||
export const NODE_RADIUS_RANGE = 9;
|
||||
|
||||
/**
|
||||
* Clamp `trust` to [0,1] before mapping to a radius so a bad FSRS value
|
||||
* can't produce a sub-zero or oversize node. Non-finite values collapse
|
||||
* to 0 (smallest radius — visually suppresses suspicious data).
|
||||
*/
|
||||
export function nodeRadius(trust: number): number {
|
||||
if (!Number.isFinite(trust)) return NODE_RADIUS_MIN;
|
||||
const t = trust < 0 ? 0 : trust > 1 ? 1 : trust;
|
||||
return NODE_RADIUS_MIN + t * NODE_RADIUS_RANGE;
|
||||
}
|
||||
|
||||
/** Clamp trust to [0,1]. NaN/Infinity/undefined → 0. */
|
||||
export function clampTrust(trust: number | null | undefined): number {
|
||||
if (trust === null || trust === undefined || !Number.isFinite(trust)) return 0;
|
||||
if (trust < 0) return 0;
|
||||
if (trust > 1) return 1;
|
||||
return trust;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Focus / pair opacity.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Opacity applied to a non-focused pair when any pair is focused. */
|
||||
export const UNFOCUSED_OPACITY = 0.12;
|
||||
|
||||
/**
|
||||
* Opacity for a pair given the current focus state.
|
||||
*
|
||||
* focus = null/undefined → 1 (nothing dimmed)
|
||||
* focus === pairIndex → 1 (the focused pair is fully lit)
|
||||
* focus !== pairIndex → 0.12 (dimmed)
|
||||
*
|
||||
* A focus index that doesn't match any rendered pair simply dims everything.
|
||||
* That's the intended "silent no-op" for a stale focusedPairIndex.
|
||||
*/
|
||||
export function pairOpacity(pairIndex: number, focusedPairIndex: number | null | undefined): number {
|
||||
if (focusedPairIndex === null || focusedPairIndex === undefined) return 1;
|
||||
return focusedPairIndex === pairIndex ? 1 : UNFOCUSED_OPACITY;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Text truncation.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Truncate a string to `max` characters with an ellipsis at the end.
|
||||
* Shorter-or-equal strings return unchanged. Empty strings return unchanged.
|
||||
* Non-string inputs collapse to '' rather than crashing.
|
||||
*
|
||||
* The ellipsis counts toward the length budget, so the cut-off content is
|
||||
* `max - 1` characters, matching the component's inline truncate() helper.
|
||||
*/
|
||||
export function truncate(s: string | null | undefined, max = 60): string {
|
||||
if (s === null || s === undefined) return '';
|
||||
if (typeof s !== 'string') return '';
|
||||
if (max <= 0) return '';
|
||||
if (s.length <= max) return s;
|
||||
return s.slice(0, max - 1) + '…';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stats.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Count unique memory IDs across a list of contradiction pairs. Each pair
|
||||
* contributes memory_a_id and memory_b_id. Duplicates (e.g. one memory that
|
||||
* appears in multiple conflicts) are counted once.
|
||||
*/
|
||||
export function uniqueMemoryCount(pairs: readonly ContradictionLike[]): number {
|
||||
if (!pairs || pairs.length === 0) return 0;
|
||||
const set = new Set<string>();
|
||||
for (const p of pairs) {
|
||||
if (p.memory_a_id) set.add(p.memory_a_id);
|
||||
if (p.memory_b_id) set.add(p.memory_b_id);
|
||||
}
|
||||
return set.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Average absolute trust delta across pairs. Returns 0 on empty input so
|
||||
* the UI can render `0.00` instead of `NaN`.
|
||||
*/
|
||||
export function avgTrustDelta(
|
||||
pairs: readonly { trust_a: number; trust_b: number }[],
|
||||
): number {
|
||||
if (!pairs || pairs.length === 0) return 0;
|
||||
let sum = 0;
|
||||
for (const p of pairs) {
|
||||
sum += Math.abs((p.trust_a ?? 0) - (p.trust_b ?? 0));
|
||||
}
|
||||
return sum / pairs.length;
|
||||
}
|
||||
155
apps/dashboard/src/lib/components/dream-helpers.ts
Normal file
155
apps/dashboard/src/lib/components/dream-helpers.ts
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
/**
|
||||
* dream-helpers — Pure logic for Dream Cinema UI.
|
||||
*
|
||||
* Extracted so we can test it without jsdom / Svelte component harnessing.
|
||||
* The Vitest setup for this package runs in a Node environment; every helper
|
||||
* in this module is a pure function of its inputs, so it can be exercised
|
||||
* directly in `__tests__/*.test.ts` alongside the graph helpers.
|
||||
*/
|
||||
|
||||
/** Stage 1..5 of the 5-phase consolidation cycle. */
|
||||
export const STAGE_COUNT = 5 as const;
|
||||
|
||||
/** Display names for each stage index (1-indexed). */
|
||||
export const STAGE_NAMES = [
|
||||
'Replay',
|
||||
'Cross-reference',
|
||||
'Strengthen',
|
||||
'Prune',
|
||||
'Transfer',
|
||||
] as const;
|
||||
|
||||
export type StageIndex = 1 | 2 | 3 | 4 | 5;
|
||||
|
||||
/**
|
||||
* Clamp an arbitrary integer to the valid 1..5 stage range. Accepts any
|
||||
* number (NaN, Infinity, negatives, floats) and always returns an integer
|
||||
* in [1,5]. NaN and non-finite values fall back to 1 — this matches the
|
||||
* "start at stage 1" behaviour on a fresh dream.
|
||||
*/
|
||||
export function clampStage(n: number): StageIndex {
|
||||
if (!Number.isFinite(n)) return 1;
|
||||
const i = Math.floor(n);
|
||||
if (i < 1) return 1;
|
||||
if (i > STAGE_COUNT) return STAGE_COUNT;
|
||||
return i as StageIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the human-readable stage name for a (possibly invalid) stage number.
|
||||
* Uses `clampStage`, so out-of-range inputs return the nearest valid name.
|
||||
*/
|
||||
export function stageName(n: number): string {
|
||||
return STAGE_NAMES[clampStage(n) - 1];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Novelty classification — drives the gold-glow / muted styling on insight
|
||||
// cards. Thresholds are STRICTLY exclusive so `0.3` and `0.7` map to the
|
||||
// neutral band on purpose. See DreamInsightCard.svelte.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type NoveltyBand = 'high' | 'neutral' | 'low';
|
||||
|
||||
/** Upper bound for the muted "low novelty" band. Values BELOW this are low. */
|
||||
export const LOW_NOVELTY_THRESHOLD = 0.3;
|
||||
/** Lower bound for the gold "high novelty" band. Values ABOVE this are high. */
|
||||
export const HIGH_NOVELTY_THRESHOLD = 0.7;
|
||||
|
||||
/**
|
||||
* Classify a novelty score into one of 3 visual bands.
|
||||
*
|
||||
* Thresholds are exclusive on both sides:
|
||||
* novelty > 0.7 → 'high' (gold glow)
|
||||
* novelty < 0.3 → 'low' (muted / desaturated)
|
||||
* otherwise → 'neutral'
|
||||
*
|
||||
* `null` / `undefined` / `NaN` collapse to 0 → 'low'.
|
||||
*/
|
||||
export function noveltyBand(novelty: number | null | undefined): NoveltyBand {
|
||||
const n = clamp01(novelty);
|
||||
if (n > HIGH_NOVELTY_THRESHOLD) return 'high';
|
||||
if (n < LOW_NOVELTY_THRESHOLD) return 'low';
|
||||
return 'neutral';
|
||||
}
|
||||
|
||||
/** Clamp a value into [0,1]. `null`/`undefined`/`NaN` → 0. */
|
||||
export function clamp01(n: number | null | undefined): number {
|
||||
if (n === null || n === undefined || !Number.isFinite(n)) return 0;
|
||||
if (n < 0) return 0;
|
||||
if (n > 1) return 1;
|
||||
return n;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Formatting helpers — mirror what the page + card render. Keeping these
|
||||
// pure lets us test the exact output strings without rendering Svelte.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Format a millisecond duration as a human-readable string.
|
||||
* < 1000ms → "{n}ms" (e.g. "0ms", "500ms")
|
||||
* ≥ 1000ms → "{n.nn}s" (e.g. "1.50s", "15.00s")
|
||||
* Negative / NaN values collapse to "0ms".
|
||||
*/
|
||||
export function formatDurationMs(ms: number | null | undefined): string {
|
||||
if (ms === null || ms === undefined || !Number.isFinite(ms) || ms < 0) {
|
||||
return '0ms';
|
||||
}
|
||||
if (ms < 1000) return `${Math.round(ms)}ms`;
|
||||
return `${(ms / 1000).toFixed(2)}s`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a 0..1 confidence as a whole-percent string ("0%", "50%", "100%").
|
||||
* Values outside [0,1] clamp first. Uses `Math.round` so 0.505 → "51%".
|
||||
*/
|
||||
export function formatConfidencePct(confidence: number | null | undefined): string {
|
||||
const c = clamp01(confidence);
|
||||
return `${Math.round(c * 100)}%`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Source memory link formatting.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build the href for a source memory link. We keep this behind a helper so
|
||||
* the route format is tested in one place. `base` corresponds to SvelteKit's
|
||||
* `$app/paths` base (may be ""). Invalid IDs still produce a URL — route
|
||||
* handling is the page's responsibility, not ours.
|
||||
*/
|
||||
export function sourceMemoryHref(id: string, base = ''): string {
|
||||
return `${base}/memories/${id}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the first N source memory IDs from an insight's `sourceMemories`
|
||||
* array, safely handling null / undefined / empty. Default N = 2, matching
|
||||
* the card's "first 2 links" behaviour.
|
||||
*/
|
||||
export function firstSourceIds(
|
||||
sources: readonly string[] | null | undefined,
|
||||
n = 2,
|
||||
): string[] {
|
||||
if (!sources || sources.length === 0) return [];
|
||||
return sources.slice(0, Math.max(0, n));
|
||||
}
|
||||
|
||||
/** Count of sources beyond the first N. Used for the "(+N)" suffix. */
|
||||
export function extraSourceCount(
|
||||
sources: readonly string[] | null | undefined,
|
||||
shown = 2,
|
||||
): number {
|
||||
if (!sources) return 0;
|
||||
return Math.max(0, sources.length - shown);
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate a memory UUID for display on the chip. Matches the previous
|
||||
* inline `shortId` logic: first 8 chars, or the whole string if shorter.
|
||||
*/
|
||||
export function shortMemoryId(id: string): string {
|
||||
if (!id) return '';
|
||||
return id.length > 8 ? id.slice(0, 8) : id;
|
||||
}
|
||||
149
apps/dashboard/src/lib/components/duplicates-helpers.ts
Normal file
149
apps/dashboard/src/lib/components/duplicates-helpers.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
/**
|
||||
* Pure helpers for the Memory Hygiene / Duplicate Detection UI.
|
||||
*
|
||||
* Extracted from DuplicateCluster.svelte + duplicates/+page.svelte so the
|
||||
* logic can be unit tested in the vitest `node` environment without jsdom.
|
||||
*
|
||||
* Contracts
|
||||
* ---------
|
||||
* - `similarityBand`: fixed thresholds at 0.92 (near-identical) and 0.80
|
||||
* (strong). Boundary values MATCH the higher band (>= semantics).
|
||||
* - `pickWinner`: highest retention wins. Ties broken by earliest index
|
||||
* (stable). Returns `null` on empty input — callers must guard.
|
||||
* - `suggestedActionFor`: >= 0.92 → 'merge', < 0.85 → 'review'. The 0.85..0.92
|
||||
* corridor follows the upstream `suggestedAction` field from the MCP tool,
|
||||
* so we only override the obvious cases. Default for the corridor is
|
||||
* whatever the caller already had — this function returns null to signal
|
||||
* "caller decides."
|
||||
* - `filterByThreshold`: strict `>=` against the provided similarity.
|
||||
* - `clusterKey`: stable identity across re-fetches — sorted member ids
|
||||
* joined. Survives threshold changes that keep the same cluster members.
|
||||
*/
|
||||
|
||||
export type SimilarityBand = 'near-identical' | 'strong' | 'weak';
|
||||
export type SuggestedAction = 'merge' | 'review';
|
||||
|
||||
export interface ClusterMemoryLike {
|
||||
id: string;
|
||||
retention: number;
|
||||
tags?: string[];
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export interface ClusterLike<M extends ClusterMemoryLike = ClusterMemoryLike> {
|
||||
similarity: number;
|
||||
memories: M[];
|
||||
}
|
||||
|
||||
/** Color bands. Boundary at 0.92 → red. Boundary at 0.80 → amber. */
|
||||
export function similarityBand(similarity: number): SimilarityBand {
|
||||
if (similarity >= 0.92) return 'near-identical';
|
||||
if (similarity >= 0.8) return 'strong';
|
||||
return 'weak';
|
||||
}
|
||||
|
||||
export function similarityBandColor(similarity: number): string {
|
||||
const band = similarityBand(similarity);
|
||||
if (band === 'near-identical') return 'var(--color-decay)';
|
||||
if (band === 'strong') return 'var(--color-warning)';
|
||||
return '#fde047'; // yellow-300 — distinct from amber warning
|
||||
}
|
||||
|
||||
export function similarityBandLabel(similarity: number): string {
|
||||
const band = similarityBand(similarity);
|
||||
if (band === 'near-identical') return 'Near-identical';
|
||||
if (band === 'strong') return 'Strong match';
|
||||
return 'Weak match';
|
||||
}
|
||||
|
||||
/** Retention color dot. Matches the traffic-light scheme. */
|
||||
export function retentionColor(retention: number): string {
|
||||
if (retention > 0.7) return '#10b981';
|
||||
if (retention > 0.4) return '#f59e0b';
|
||||
return '#ef4444';
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the highest-retention memory. Stable tie-break: earliest wins.
|
||||
* Returns `null` if the cluster is empty. Treats non-finite retention as
|
||||
* -Infinity so a `retention=NaN` row never claims the throne.
|
||||
*/
|
||||
export function pickWinner<M extends ClusterMemoryLike>(memories: M[]): M | null {
|
||||
if (!memories || memories.length === 0) return null;
|
||||
let best = memories[0];
|
||||
let bestScore = Number.isFinite(best.retention) ? best.retention : -Infinity;
|
||||
for (let i = 1; i < memories.length; i++) {
|
||||
const m = memories[i];
|
||||
const s = Number.isFinite(m.retention) ? m.retention : -Infinity;
|
||||
if (s > bestScore) {
|
||||
best = m;
|
||||
bestScore = s;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Suggested action inference. Returns null in the ambiguous 0.85..0.92 band
|
||||
* so callers can honor an upstream suggestion from the backend.
|
||||
*/
|
||||
export function suggestedActionFor(similarity: number): SuggestedAction | null {
|
||||
if (similarity >= 0.92) return 'merge';
|
||||
if (similarity < 0.85) return 'review';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter clusters by the >= threshold contract. Separate pure function so the
|
||||
* mock fetch and any future real fetch both get the same semantics.
|
||||
*/
|
||||
export function filterByThreshold<C extends ClusterLike>(clusters: C[], threshold: number): C[] {
|
||||
return clusters.filter((c) => c.similarity >= threshold);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable identity across re-fetches. Uses sorted member ids, so a cluster
|
||||
* that loses/gains a member gets a new key (intentional — the cluster has
|
||||
* changed). If you dismissed cluster [A,B,C] at 0.80 and refetch at 0.70
|
||||
* and it now contains [A,B,C,D], it reappears — correct behaviour: a new
|
||||
* member deserves fresh attention.
|
||||
*/
|
||||
export function clusterKey<M extends ClusterMemoryLike>(memories: M[]): string {
|
||||
return memories
|
||||
.map((m) => m.id)
|
||||
.slice()
|
||||
.sort()
|
||||
.join('|');
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe content preview — trims, collapses whitespace, truncates at 80 chars
|
||||
* with an ellipsis. Null-safe.
|
||||
*/
|
||||
export function previewContent(content: string | null | undefined, max: number = 80): string {
|
||||
if (!content) return '';
|
||||
const trimmed = content.trim().replace(/\s+/g, ' ');
|
||||
return trimmed.length <= max ? trimmed : trimmed.slice(0, max) + '…';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render an ISO date string safely — returns an empty string for missing,
|
||||
* non-string, or invalid input so the DOM shows nothing rather than
|
||||
* "Invalid Date".
|
||||
*/
|
||||
export function formatDate(iso: string | null | undefined): string {
|
||||
if (!iso || typeof iso !== 'string') return '';
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
return d.toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
/** Safe tag slice — tolerates undefined or non-array inputs. */
|
||||
export function safeTags(tags: string[] | null | undefined, limit: number = 4): string[] {
|
||||
if (!Array.isArray(tags)) return [];
|
||||
return tags.slice(0, limit);
|
||||
}
|
||||
226
apps/dashboard/src/lib/components/importance-helpers.ts
Normal file
226
apps/dashboard/src/lib/components/importance-helpers.ts
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
/**
|
||||
* importance-helpers — Pure logic for the Importance Radar UI
|
||||
* (importance/+page.svelte + ImportanceRadar.svelte).
|
||||
*
|
||||
* Extracted so the radar geometry and importance-proxy maths can be unit-
|
||||
* tested in the vitest `node` environment without jsdom or Svelte harness.
|
||||
*
|
||||
* Contracts
|
||||
* ---------
|
||||
* - Backend channel weights (novelty 0.25, arousal 0.30, reward 0.25,
|
||||
* attention 0.20) sum to 1.0 and mirror ImportanceSignals in vestige-core.
|
||||
* - `clamp01` folds NaN/Infinity/nullish → 0 and clips [0,1].
|
||||
* - `radarVertices` emits 4 SVG polygon points in the fixed axis order
|
||||
* Novelty (top) → Arousal (right) → Reward (bottom) → Attention (left).
|
||||
* A zero value places the vertex at centre; a one value places it at the
|
||||
* unit-ring edge.
|
||||
* - `importanceProxy` is the SAME formula the page uses to rank the weekly
|
||||
* list: retentionStrength × log1p(reviews + 1) / sqrt(max(1, ageDays)).
|
||||
* Age is clamped to 1 so a freshly-created memory never divides by zero.
|
||||
* - `sizePreset` maps 'sm'|'md'|'lg' to 80|180|320 and defaults to 'md' for
|
||||
* any unknown size key — matching the component's default prop.
|
||||
*/
|
||||
|
||||
// -- Channel model ----------------------------------------------------------
|
||||
|
||||
export type ChannelKey = 'novelty' | 'arousal' | 'reward' | 'attention';
|
||||
|
||||
/** Weights applied server-side by ImportanceSignals. Must sum to 1.0. */
|
||||
export const CHANNEL_WEIGHTS: Readonly<Record<ChannelKey, number>> = {
|
||||
novelty: 0.25,
|
||||
arousal: 0.3,
|
||||
reward: 0.25,
|
||||
attention: 0.2,
|
||||
} as const;
|
||||
|
||||
export interface Channels {
|
||||
novelty: number;
|
||||
arousal: number;
|
||||
reward: number;
|
||||
attention: number;
|
||||
}
|
||||
|
||||
/** Clamp a value to [0,1]. Null / undefined / NaN / Infinity → 0. */
|
||||
export function clamp01(v: number | null | undefined): number {
|
||||
if (v === null || v === undefined) return 0;
|
||||
if (!Number.isFinite(v)) return 0;
|
||||
if (v < 0) return 0;
|
||||
if (v > 1) return 1;
|
||||
return v;
|
||||
}
|
||||
|
||||
/** Clamp every channel to [0,1]. Safe for partial / malformed inputs. */
|
||||
export function clampChannels(ch: Partial<Channels> | null | undefined): Channels {
|
||||
return {
|
||||
novelty: clamp01(ch?.novelty),
|
||||
arousal: clamp01(ch?.arousal),
|
||||
reward: clamp01(ch?.reward),
|
||||
attention: clamp01(ch?.attention),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Composite importance score — matches backend ImportanceSignals.
|
||||
*
|
||||
* composite = 0.25·novelty + 0.30·arousal + 0.25·reward + 0.20·attention
|
||||
*
|
||||
* Every input is clamped first so out-of-range channels never puncture the
|
||||
* 0..1 composite range. The return value is guaranteed to be in [0,1].
|
||||
*/
|
||||
export function compositeScore(ch: Partial<Channels> | null | undefined): number {
|
||||
const c = clampChannels(ch);
|
||||
return (
|
||||
c.novelty * CHANNEL_WEIGHTS.novelty +
|
||||
c.arousal * CHANNEL_WEIGHTS.arousal +
|
||||
c.reward * CHANNEL_WEIGHTS.reward +
|
||||
c.attention * CHANNEL_WEIGHTS.attention
|
||||
);
|
||||
}
|
||||
|
||||
// -- Size preset ------------------------------------------------------------
|
||||
|
||||
export type RadarSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
export const SIZE_PX: Readonly<Record<RadarSize, number>> = {
|
||||
sm: 80,
|
||||
md: 180,
|
||||
lg: 320,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Resolve a size preset key to its px value. Unknown / missing keys fall
|
||||
* back to 'md' (180), matching the component's default prop. `sm` loses
|
||||
* axis labels in the renderer but that's rendering concern, not ours.
|
||||
*/
|
||||
export function sizePreset(size: RadarSize | string | undefined): number {
|
||||
if (size && (size === 'sm' || size === 'md' || size === 'lg')) {
|
||||
return SIZE_PX[size];
|
||||
}
|
||||
return SIZE_PX.md;
|
||||
}
|
||||
|
||||
// -- Geometry ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Fixed axis order. Angles use SVG conventions (y grows downward):
|
||||
* Novelty → angle -π/2 (top)
|
||||
* Arousal → angle 0 (right)
|
||||
* Reward → angle π/2 (bottom)
|
||||
* Attention → angle π (left)
|
||||
*/
|
||||
export const AXIS_ORDER: ReadonlyArray<{ key: ChannelKey; angle: number }> = [
|
||||
{ key: 'novelty', angle: -Math.PI / 2 },
|
||||
{ key: 'arousal', angle: 0 },
|
||||
{ key: 'reward', angle: Math.PI / 2 },
|
||||
{ key: 'attention', angle: Math.PI },
|
||||
] as const;
|
||||
|
||||
export interface RadarPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the effective drawable radius inside the SVG box. This mirrors the
|
||||
* component's padding logic:
|
||||
* sm → padding 4 (edge-to-edge, no labels)
|
||||
* md → padding 28
|
||||
* lg → padding 44
|
||||
* Radius = size/2 − padding, floored at 0 (a radius below zero would draw
|
||||
* an inverted polygon — defensive guard).
|
||||
*/
|
||||
export function radarRadius(size: RadarSize | string | undefined): number {
|
||||
const px = sizePreset(size);
|
||||
let padding: number;
|
||||
switch (size) {
|
||||
case 'lg':
|
||||
padding = 44;
|
||||
break;
|
||||
case 'sm':
|
||||
padding = 4;
|
||||
break;
|
||||
default:
|
||||
padding = 28;
|
||||
}
|
||||
return Math.max(0, px / 2 - padding);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the 4 SVG polygon vertices for a set of channel values at a given
|
||||
* radar size. Values are clamped to [0,1] first so out-of-range inputs can't
|
||||
* escape the radar bounds.
|
||||
*
|
||||
* Ordering is FIXED and matches AXIS_ORDER: [novelty, arousal, reward, attention].
|
||||
* A zero value places the vertex at the centre (cx, cy); a one value places
|
||||
* it at the unit-ring edge.
|
||||
*/
|
||||
export function radarVertices(
|
||||
ch: Partial<Channels> | null | undefined,
|
||||
size: RadarSize | string | undefined = 'md',
|
||||
): RadarPoint[] {
|
||||
const px = sizePreset(size);
|
||||
const r = radarRadius(size);
|
||||
const cx = px / 2;
|
||||
const cy = px / 2;
|
||||
const values = clampChannels(ch);
|
||||
return AXIS_ORDER.map(({ key, angle }) => {
|
||||
const v = values[key];
|
||||
return {
|
||||
x: cx + Math.cos(angle) * v * r,
|
||||
y: cy + Math.sin(angle) * v * r,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Serialise vertices to an SVG "M…L…L…L… Z" path, 2-decimal precision. */
|
||||
export function verticesToPath(points: RadarPoint[]): string {
|
||||
if (points.length === 0) return '';
|
||||
return (
|
||||
points
|
||||
.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x.toFixed(2)},${p.y.toFixed(2)}`)
|
||||
.join(' ') + ' Z'
|
||||
);
|
||||
}
|
||||
|
||||
// -- Trending-memory proxy --------------------------------------------------
|
||||
|
||||
export interface ProxyMemoryLike {
|
||||
retentionStrength: number;
|
||||
reviewCount?: number | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy score for the "Top Important Memories This Week" ranking. Exact
|
||||
* formula from importance/+page.svelte:
|
||||
*
|
||||
* ageDays = max(1, (now - createdAt) / 86_400_000)
|
||||
* reviews = reviewCount ?? 0
|
||||
* recencyBoost = 1 / sqrt(ageDays)
|
||||
* proxy = retentionStrength × log1p(reviews + 1) × recencyBoost
|
||||
*
|
||||
* Edge cases:
|
||||
* - createdAt is the current instant → ageDays clamps to 1 (no div-by-0)
|
||||
* - createdAt is in the future → negative age also clamps to 1
|
||||
* - reviewCount null/undefined → treated as 0
|
||||
* - non-finite retentionStrength → returns 0 defensively
|
||||
*
|
||||
* `now` is injectable for deterministic tests. Defaults to `Date.now()`.
|
||||
*/
|
||||
export function importanceProxy(m: ProxyMemoryLike, now: number = Date.now()): number {
|
||||
if (!m || !Number.isFinite(m.retentionStrength)) return 0;
|
||||
const created = new Date(m.createdAt).getTime();
|
||||
if (!Number.isFinite(created)) return 0;
|
||||
const ageDays = Math.max(1, (now - created) / 86_400_000);
|
||||
const reviews = m.reviewCount ?? 0;
|
||||
const recencyBoost = 1 / Math.sqrt(ageDays);
|
||||
return m.retentionStrength * Math.log1p(reviews + 1) * recencyBoost;
|
||||
}
|
||||
|
||||
/** Sort memories by the proxy, descending. Stable via `.sort` on a copy. */
|
||||
export function rankByProxy<M extends ProxyMemoryLike>(
|
||||
memories: readonly M[],
|
||||
now: number = Date.now(),
|
||||
): M[] {
|
||||
return memories.slice().sort((a, b) => importanceProxy(b, now) - importanceProxy(a, now));
|
||||
}
|
||||
178
apps/dashboard/src/lib/components/patterns-helpers.ts
Normal file
178
apps/dashboard/src/lib/components/patterns-helpers.ts
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
/**
|
||||
* patterns-helpers — Pure logic for the Cross-Project Intelligence UI
|
||||
* (patterns/+page.svelte + PatternTransferHeatmap.svelte).
|
||||
*
|
||||
* Extracted so the behaviour can be unit-tested in the vitest `node`
|
||||
* environment without jsdom or Svelte component harnessing. Every helper
|
||||
* in this module is a pure function of its inputs.
|
||||
*
|
||||
* Contracts
|
||||
* ---------
|
||||
* - `cellIntensity`: returns opacity in [0,1] from count / max. count=0 → 0,
|
||||
* count>=max → 1. `max<=0` collapses to 0 (avoids div-by-zero — the
|
||||
* component uses `max || 1` for the same reason).
|
||||
* - `filterByCategory`: 'All' passes every pattern through. An unknown
|
||||
* category string (not one of the 6 + 'All') returns an empty array —
|
||||
* there is no hidden alias fallback.
|
||||
* - `buildTransferMatrix`: directional. `matrix[origin][dest]` counts how
|
||||
* many patterns originated in `origin` and were transferred to `dest`.
|
||||
* `origin === dest` captures self-transfer (a project reusing its own
|
||||
* pattern — rare but real per the component's doc comment).
|
||||
*/
|
||||
|
||||
export const PATTERN_CATEGORIES = [
|
||||
'ErrorHandling',
|
||||
'AsyncConcurrency',
|
||||
'Testing',
|
||||
'Architecture',
|
||||
'Performance',
|
||||
'Security',
|
||||
] as const;
|
||||
|
||||
export type PatternCategory = (typeof PATTERN_CATEGORIES)[number];
|
||||
export type CategoryFilter = 'All' | PatternCategory;
|
||||
|
||||
export interface TransferPatternLike {
|
||||
name: string;
|
||||
category: PatternCategory;
|
||||
origin_project: string;
|
||||
transferred_to: string[];
|
||||
transfer_count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise a raw transfer count to a 0..1 opacity/intensity value against a
|
||||
* known max. Used by the heatmap cell colour ramp.
|
||||
*
|
||||
* count <= 0 → 0 (dead cell)
|
||||
* count >= max > 0 → 1 (hottest cell)
|
||||
* otherwise → count / max
|
||||
*
|
||||
* Non-finite / negative inputs collapse to 0. When `max <= 0` the result is
|
||||
* always 0 — the component's own guard (`maxCount || 1`) means this branch
|
||||
* is unreachable in practice, but defensive anyway.
|
||||
*/
|
||||
export function cellIntensity(count: number, max: number): number {
|
||||
if (!Number.isFinite(count) || count <= 0) return 0;
|
||||
if (!Number.isFinite(max) || max <= 0) return 0;
|
||||
if (count >= max) return 1;
|
||||
return count / max;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter a pattern list by the active category tab.
|
||||
* 'All' → full pass-through (same reference-equal array is
|
||||
* NOT guaranteed; callers must not rely on identity)
|
||||
* one of the 6 enums → strict equality on `category`
|
||||
* unknown string → empty array (no silent alias; caller bug)
|
||||
*/
|
||||
export function filterByCategory<P extends TransferPatternLike>(
|
||||
patterns: readonly P[],
|
||||
category: CategoryFilter | string,
|
||||
): P[] {
|
||||
if (category === 'All') return patterns.slice();
|
||||
if (!(PATTERN_CATEGORIES as readonly string[]).includes(category)) {
|
||||
return [];
|
||||
}
|
||||
return patterns.filter((p) => p.category === category);
|
||||
}
|
||||
|
||||
/** Cell in the directional N×N transfer matrix. */
|
||||
export interface TransferCell {
|
||||
count: number;
|
||||
topNames: string[];
|
||||
}
|
||||
|
||||
/** Dense row-major directional matrix: matrix[origin][destination]. */
|
||||
export type TransferMatrix = Record<string, Record<string, TransferCell>>;
|
||||
|
||||
/**
|
||||
* Build the directional transfer matrix from patterns + the known projects
|
||||
* axis. Mirrors `PatternTransferHeatmap.svelte`'s `$derived` logic.
|
||||
*
|
||||
* - Every (from, to) pair in `projects × projects` gets a zero cell.
|
||||
* - Each pattern P contributes `+1` to `matrix[P.origin][dest]` for every
|
||||
* `dest` in `P.transferred_to` that also appears in `projects`.
|
||||
* - Patterns whose origin isn't in `projects` are silently skipped — that
|
||||
* matches the component's `if (!m[from]) continue` guard.
|
||||
* - `topNames` holds up to 3 pattern names per cell in insertion order.
|
||||
*/
|
||||
export function buildTransferMatrix(
|
||||
projects: readonly string[],
|
||||
patterns: readonly TransferPatternLike[],
|
||||
topNameCap = 3,
|
||||
): TransferMatrix {
|
||||
const m: TransferMatrix = {};
|
||||
for (const from of projects) {
|
||||
m[from] = {};
|
||||
for (const to of projects) {
|
||||
m[from][to] = { count: 0, topNames: [] };
|
||||
}
|
||||
}
|
||||
for (const p of patterns) {
|
||||
const from = p.origin_project;
|
||||
if (!m[from]) continue;
|
||||
for (const to of p.transferred_to) {
|
||||
if (!m[from][to]) continue;
|
||||
m[from][to].count += 1;
|
||||
m[from][to].topNames.push(p.name);
|
||||
}
|
||||
}
|
||||
const cap = Math.max(0, topNameCap);
|
||||
for (const from of projects) {
|
||||
for (const to of projects) {
|
||||
m[from][to].topNames = m[from][to].topNames.slice(0, cap);
|
||||
}
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maximum single-cell transfer count across the matrix. Floors at 0 for an
|
||||
* empty matrix, which callers should treat as "scale by 1" to avoid a div-
|
||||
* by-zero in the colour ramp.
|
||||
*/
|
||||
export function matrixMaxCount(
|
||||
projects: readonly string[],
|
||||
matrix: TransferMatrix,
|
||||
): number {
|
||||
let max = 0;
|
||||
for (const from of projects) {
|
||||
const row = matrix[from];
|
||||
if (!row) continue;
|
||||
for (const to of projects) {
|
||||
const cell = row[to];
|
||||
if (cell && cell.count > max) max = cell.count;
|
||||
}
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten a matrix into sorted-desc rows for the mobile fallback. Only
|
||||
* non-zero pairs are emitted, matching the component.
|
||||
*/
|
||||
export function flattenNonZero(
|
||||
projects: readonly string[],
|
||||
matrix: TransferMatrix,
|
||||
): Array<{ from: string; to: string; count: number; topNames: string[] }> {
|
||||
const rows: Array<{ from: string; to: string; count: number; topNames: string[] }> = [];
|
||||
for (const from of projects) {
|
||||
for (const to of projects) {
|
||||
const cell = matrix[from]?.[to];
|
||||
if (cell && cell.count > 0) {
|
||||
rows.push({ from, to, count: cell.count, topNames: cell.topNames });
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows.sort((a, b) => b.count - a.count);
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate long project names for axis labels. Match the component's
|
||||
* `shortProject` behaviour: keep ≤12 chars, otherwise 11-char prefix + ellipsis.
|
||||
*/
|
||||
export function shortProjectName(name: string): string {
|
||||
if (!name) return '';
|
||||
return name.length > 12 ? name.slice(0, 11) + '…' : name;
|
||||
}
|
||||
229
apps/dashboard/src/lib/components/reasoning-helpers.ts
Normal file
229
apps/dashboard/src/lib/components/reasoning-helpers.ts
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
/**
|
||||
* reasoning-helpers — Pure logic for the Reasoning Theater UI.
|
||||
*
|
||||
* Extracted so we can test it without jsdom / Svelte component harnessing.
|
||||
* The Vitest setup for this package runs in a Node environment; every helper
|
||||
* in this module is a pure function of its inputs, so it can be exercised
|
||||
* directly in `__tests__/*.test.ts` alongside the graph helpers.
|
||||
*/
|
||||
import { NODE_TYPE_COLORS } from '$types';
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Shared palette — keep in sync with Tailwind @theme values.
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
export const CONFIDENCE_EMERALD = '#10b981';
|
||||
export const CONFIDENCE_AMBER = '#f59e0b';
|
||||
export const CONFIDENCE_RED = '#ef4444';
|
||||
|
||||
/** Fallback colour when a node-type has no mapping. */
|
||||
export const DEFAULT_NODE_TYPE_COLOR = '#8B95A5';
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Roles
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
export type EvidenceRole = 'primary' | 'supporting' | 'contradicting' | 'superseded';
|
||||
|
||||
export interface RoleMeta {
|
||||
label: string;
|
||||
/** Tailwind / CSS colour token — see app.css. */
|
||||
accent: 'synapse' | 'recall' | 'decay' | 'muted';
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export const ROLE_META: Record<EvidenceRole, RoleMeta> = {
|
||||
primary: { label: 'Primary', accent: 'synapse', icon: '◈' },
|
||||
supporting: { label: 'Supporting', accent: 'recall', icon: '◇' },
|
||||
contradicting: { label: 'Contradicting', accent: 'decay', icon: '⚠' },
|
||||
superseded: { label: 'Superseded', accent: 'muted', icon: '⊘' },
|
||||
};
|
||||
|
||||
/** Look up role metadata with a defensive fallback. */
|
||||
export function roleMetaFor(role: EvidenceRole | string): RoleMeta {
|
||||
return (ROLE_META as Record<string, RoleMeta>)[role] ?? ROLE_META.supporting;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Intent classification (deep_reference `intent` field)
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
export type IntentKey =
|
||||
| 'FactCheck'
|
||||
| 'Timeline'
|
||||
| 'RootCause'
|
||||
| 'Comparison'
|
||||
| 'Synthesis';
|
||||
|
||||
export interface IntentHint {
|
||||
label: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const INTENT_HINTS: Record<IntentKey, IntentHint> = {
|
||||
FactCheck: {
|
||||
label: 'FactCheck',
|
||||
icon: '◆',
|
||||
description: 'Direct verification of a single claim.',
|
||||
},
|
||||
Timeline: {
|
||||
label: 'Timeline',
|
||||
icon: '↗',
|
||||
description: 'Ordered evolution of a fact over time.',
|
||||
},
|
||||
RootCause: {
|
||||
label: 'RootCause',
|
||||
icon: '⚡',
|
||||
description: 'Why did this happen — causal chain.',
|
||||
},
|
||||
Comparison: {
|
||||
label: 'Comparison',
|
||||
icon: '⬡',
|
||||
description: 'Contrasting two or more options side-by-side.',
|
||||
},
|
||||
Synthesis: {
|
||||
label: 'Synthesis',
|
||||
icon: '❖',
|
||||
description: 'Cross-memory composition into a new insight.',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Map an arbitrary intent string to a hint. Unknown intents degrade to
|
||||
* Synthesis, which is the most generic classification.
|
||||
*/
|
||||
export function intentHintFor(intent: string | undefined | null): IntentHint {
|
||||
if (!intent) return INTENT_HINTS.Synthesis;
|
||||
const key = intent as IntentKey;
|
||||
return INTENT_HINTS[key] ?? INTENT_HINTS.Synthesis;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Confidence bands
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Confidence colour band.
|
||||
*
|
||||
* > 75 → emerald (HIGH)
|
||||
* 40-75 → amber (MIXED)
|
||||
* < 40 → red (LOW)
|
||||
*
|
||||
* Boundaries: 75 is amber (strictly greater than 75 is emerald), 40 is amber
|
||||
* (>=40 is amber). Any non-finite input (NaN) is treated as lowest confidence
|
||||
* and returns red.
|
||||
*/
|
||||
export function confidenceColor(c: number): string {
|
||||
if (!Number.isFinite(c)) return CONFIDENCE_RED;
|
||||
if (c > 75) return CONFIDENCE_EMERALD;
|
||||
if (c >= 40) return CONFIDENCE_AMBER;
|
||||
return CONFIDENCE_RED;
|
||||
}
|
||||
|
||||
/** Human-readable label for a confidence score (0-100). */
|
||||
export function confidenceLabel(c: number): string {
|
||||
if (!Number.isFinite(c)) return 'LOW CONFIDENCE';
|
||||
if (c > 75) return 'HIGH CONFIDENCE';
|
||||
if (c >= 40) return 'MIXED SIGNAL';
|
||||
return 'LOW CONFIDENCE';
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a 0-1 trust score to the same confidence band.
|
||||
*
|
||||
* Thresholds: >0.75 emerald, 0.40-0.75 amber, <0.40 red.
|
||||
* Matches `confidenceColor` semantics so the trust bar on an evidence card
|
||||
* and the confidence meter on the page agree at the boundaries.
|
||||
*/
|
||||
export function trustColor(t: number): string {
|
||||
if (!Number.isFinite(t)) return CONFIDENCE_RED;
|
||||
return confidenceColor(t * 100);
|
||||
}
|
||||
|
||||
/** Clamp a trust score into the display range [0, 1]. */
|
||||
export function clampTrust(t: number): number {
|
||||
if (!Number.isFinite(t)) return 0;
|
||||
if (t < 0) return 0;
|
||||
if (t > 1) return 1;
|
||||
return t;
|
||||
}
|
||||
|
||||
/** Trust as a 0-100 percentage suitable for width / label rendering. */
|
||||
export function trustPercent(t: number): number {
|
||||
return clampTrust(t) * 100;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Node-type colouring
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Resolve a node-type colour with a soft-steel fallback. */
|
||||
export function nodeTypeColor(nodeType?: string | null): string {
|
||||
if (!nodeType) return DEFAULT_NODE_TYPE_COLOR;
|
||||
return NODE_TYPE_COLORS[nodeType] ?? DEFAULT_NODE_TYPE_COLOR;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Date formatting
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Format an ISO date string for EvidenceCard display.
|
||||
*
|
||||
* Handles three failure modes that `new Date(str)` alone does not:
|
||||
* 1. Empty / null / undefined → returns '—'
|
||||
* 2. Unparseable string (NaN) → returns the original string unchanged
|
||||
* 3. Non-ISO but parseable → best-effort locale format
|
||||
*
|
||||
* The previous try/catch-only approach silently rendered the literal text
|
||||
* "Invalid Date" because `Date` never throws on bad input — it produces a
|
||||
* valid object whose getTime() is NaN.
|
||||
*/
|
||||
export function formatDate(
|
||||
iso: string | null | undefined,
|
||||
locale?: string,
|
||||
): string {
|
||||
if (iso == null) return '—';
|
||||
if (typeof iso !== 'string' || iso.trim() === '') return '—';
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
try {
|
||||
return d.toLocaleDateString(locale, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
/** Compact month/day formatter for the evolution timeline. */
|
||||
export function formatShortDate(
|
||||
iso: string | null | undefined,
|
||||
locale?: string,
|
||||
): string {
|
||||
if (iso == null) return '—';
|
||||
if (typeof iso !== 'string' || iso.trim() === '') return '—';
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
try {
|
||||
return d.toLocaleDateString(locale, { month: 'short', day: 'numeric' });
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Short-id for #abcdef01 style display
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Return the first 8 characters of an id, or the full string if shorter.
|
||||
* Never throws on null/undefined — returns '' so the caller can render '#'.
|
||||
*/
|
||||
export function shortenId(id: string | null | undefined, length = 8): string {
|
||||
if (!id) return '';
|
||||
return id.length > length ? id.slice(0, length) : id;
|
||||
}
|
||||
161
apps/dashboard/src/lib/components/schedule-helpers.ts
Normal file
161
apps/dashboard/src/lib/components/schedule-helpers.ts
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
/**
|
||||
* Pure helpers for the FSRS review schedule page + calendar.
|
||||
*
|
||||
* Extracted from `FSRSCalendar.svelte` and `routes/(app)/schedule/+page.svelte`
|
||||
* so that bucket / grid / urgency / retention math can be tested in isolation
|
||||
* (vitest `environment: node`, no jsdom required).
|
||||
*/
|
||||
import type { Memory } from '$types';
|
||||
|
||||
export const MS_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Zero-out the time component of a date, returning a NEW Date at local
|
||||
* midnight. Used for day-granular bucketing so comparisons are stable across
|
||||
* any hour-of-day the user loads the page.
|
||||
*/
|
||||
export function startOfDay(d: Date | string): Date {
|
||||
const x = typeof d === 'string' ? new Date(d) : new Date(d);
|
||||
x.setHours(0, 0, 0, 0);
|
||||
return x;
|
||||
}
|
||||
|
||||
/**
|
||||
* Signed integer count of whole local days between two timestamps, normalized
|
||||
* to midnight. Positive means `a` is in the future relative to `b`, negative
|
||||
* means `a` is in the past. Zero means same calendar day.
|
||||
*/
|
||||
export function daysBetween(a: Date, b: Date): number {
|
||||
return Math.floor((startOfDay(a).getTime() - startOfDay(b).getTime()) / MS_DAY);
|
||||
}
|
||||
|
||||
/** YYYY-MM-DD in LOCAL time (not UTC) so calendar cells align with user's day. */
|
||||
export function isoDate(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Urgency bucket for a review date relative to "now". Used by the right-hand
|
||||
* list and the calendar cell color. Day-granular (not hour-granular) so a
|
||||
* memory due at 23:59 today does not suddenly become "in 1d" at 00:01
|
||||
* tomorrow UX-wise — it becomes "overdue" cleanly at midnight.
|
||||
*
|
||||
* - `none` — no valid `nextReviewAt`
|
||||
* - `overdue` — due date's calendar day is strictly before today
|
||||
* - `today` — due date's calendar day is today
|
||||
* - `week` — due in 1..=7 whole days
|
||||
* - `future` — due in 8+ whole days
|
||||
*/
|
||||
export type Urgency = 'none' | 'overdue' | 'today' | 'week' | 'future';
|
||||
|
||||
export function classifyUrgency(now: Date, nextReviewAt: string | null | undefined): Urgency {
|
||||
if (!nextReviewAt) return 'none';
|
||||
const d = new Date(nextReviewAt);
|
||||
if (Number.isNaN(d.getTime())) return 'none';
|
||||
const delta = daysBetween(d, now);
|
||||
if (delta < 0) return 'overdue';
|
||||
if (delta === 0) return 'today';
|
||||
if (delta <= 7) return 'week';
|
||||
return 'future';
|
||||
}
|
||||
|
||||
/**
|
||||
* Signed whole-day count from today → due date. Negative means overdue by
|
||||
* |n| days; zero means today; positive means n days out. Returns `null`
|
||||
* if the ISO string is invalid or missing.
|
||||
*/
|
||||
export function daysUntilReview(now: Date, nextReviewAt: string | null | undefined): number | null {
|
||||
if (!nextReviewAt) return null;
|
||||
const d = new Date(nextReviewAt);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
return daysBetween(d, now);
|
||||
}
|
||||
|
||||
/**
|
||||
* The [start, end) window for the week containing `d`, starting Sunday at
|
||||
* local midnight. End is the following Sunday at local midnight — exclusive.
|
||||
*/
|
||||
export function weekBucketRange(d: Date): { start: Date; end: Date } {
|
||||
const start = startOfDay(d);
|
||||
start.setDate(start.getDate() - start.getDay()); // back to Sunday
|
||||
const end = new Date(start);
|
||||
end.setDate(end.getDate() + 7);
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
/**
|
||||
* Mean retention strength across a list of memories. Returns 0 for an empty
|
||||
* list (never NaN) so the sidebar can safely render "0%".
|
||||
*/
|
||||
export function avgRetention(memories: Memory[]): number {
|
||||
if (memories.length === 0) return 0;
|
||||
let sum = 0;
|
||||
for (const m of memories) sum += m.retentionStrength ?? 0;
|
||||
return sum / memories.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a day-index `i` into a 42-cell calendar grid (6 rows × 7 cols), return
|
||||
* its row / column. The grid is laid out row-major: cell 0 = row 0 col 0,
|
||||
* cell 7 = row 1 col 0, cell 41 = row 5 col 6. Returns `null` for indices
|
||||
* outside `[0, 42)`.
|
||||
*/
|
||||
export function gridCellPosition(i: number): { row: number; col: number } | null {
|
||||
if (!Number.isInteger(i) || i < 0 || i >= 42) return null;
|
||||
return { row: Math.floor(i / 7), col: i % 7 };
|
||||
}
|
||||
|
||||
/**
|
||||
* The inverse: given a calendar anchor date (today), compute the Sunday
|
||||
* at-or-before `anchor - 14 days` that seeds row 0 of the 6×7 grid. Pure,
|
||||
* deterministic, local-time.
|
||||
*/
|
||||
export function gridStartForAnchor(anchor: Date): Date {
|
||||
const base = startOfDay(anchor);
|
||||
base.setDate(base.getDate() - 14);
|
||||
base.setDate(base.getDate() - base.getDay()); // back to Sunday
|
||||
return base;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bucket counts used by the sidebar stats block. Day-granular, consistent
|
||||
* with `classifyUrgency`.
|
||||
*/
|
||||
export interface ScheduleStats {
|
||||
overdue: number;
|
||||
dueToday: number;
|
||||
dueThisWeek: number;
|
||||
dueThisMonth: number;
|
||||
avgDays: number;
|
||||
}
|
||||
|
||||
export function computeScheduleStats(now: Date, scheduled: Memory[]): ScheduleStats {
|
||||
let overdue = 0;
|
||||
let dueToday = 0;
|
||||
let dueThisWeek = 0;
|
||||
let dueThisMonth = 0;
|
||||
let sumDays = 0;
|
||||
let futureCount = 0;
|
||||
const today = startOfDay(now);
|
||||
for (const m of scheduled) {
|
||||
if (!m.nextReviewAt) continue;
|
||||
const d = new Date(m.nextReviewAt);
|
||||
if (Number.isNaN(d.getTime())) continue;
|
||||
const delta = daysBetween(d, now);
|
||||
if (delta < 0) overdue++;
|
||||
if (delta <= 0) dueToday++;
|
||||
if (delta <= 7) dueThisWeek++;
|
||||
if (delta <= 30) dueThisMonth++;
|
||||
if (delta >= 0) {
|
||||
// Use hour-resolution days-until for the average so "due in 2.3 days"
|
||||
// is informative even when bucketing is day-granular elsewhere.
|
||||
sumDays += (d.getTime() - today.getTime()) / MS_DAY;
|
||||
futureCount++;
|
||||
}
|
||||
}
|
||||
const avgDays = futureCount > 0 ? sumDays / futureCount : 0;
|
||||
return { overdue, dueToday, dueThisWeek, dueThisMonth, avgDays };
|
||||
}
|
||||
|
|
@ -497,4 +497,505 @@ describe('EffectManager', () => {
|
|||
expect(effects.pulseEffects.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createBirthOrb (v2.3 Memory Birth Ritual)', () => {
|
||||
// Build a camera with a Quaternion for createBirthOrb's view-space
|
||||
// projection. The three-mock's applyQuaternion is identity, so the
|
||||
// start position collapses to `camera.position + (0, 0, -distance)`.
|
||||
function makeCamera() {
|
||||
return {
|
||||
position: new Vector3(0, 30, 80),
|
||||
quaternion: new (class {
|
||||
x = 0; y = 0; z = 0; w = 1;
|
||||
})(),
|
||||
} as any;
|
||||
}
|
||||
|
||||
it('adds exactly 2 sprites to the scene on spawn', () => {
|
||||
const cam = makeCamera();
|
||||
const baseline = scene.children.length;
|
||||
effects.createBirthOrb(
|
||||
cam,
|
||||
new Color(0x00ffd1) as any,
|
||||
() => new Vector3(0, 0, 0) as any,
|
||||
() => {}
|
||||
);
|
||||
expect(scene.children.length).toBe(baseline + 2);
|
||||
});
|
||||
|
||||
it('both sprite and core use additive blending', () => {
|
||||
const cam = makeCamera();
|
||||
effects.createBirthOrb(
|
||||
cam,
|
||||
new Color(0xff8800) as any,
|
||||
() => new Vector3(0, 0, 0) as any,
|
||||
() => {}
|
||||
);
|
||||
const halo = scene.children[0] as any;
|
||||
const core = scene.children[1] as any;
|
||||
// AdditiveBlending constant from three-mock is 2
|
||||
expect(halo.material.blending).toBe(2);
|
||||
expect(core.material.blending).toBe(2);
|
||||
// depthTest:false is passed to the SpriteMaterial constructor in
|
||||
// effects.ts so the orb stays visible through other nodes. The
|
||||
// three-mock's SpriteMaterial constructor does not persist this
|
||||
// param, so we can't assert it at the instance level here; the
|
||||
// production behavior is covered by ui-fixes.test.ts source grep.
|
||||
expect(halo.material.transparent).toBe(true);
|
||||
expect(core.material.transparent).toBe(true);
|
||||
});
|
||||
|
||||
it('positions the orb at camera-relative cosmic center on spawn', () => {
|
||||
const cam = makeCamera();
|
||||
effects.createBirthOrb(
|
||||
cam,
|
||||
new Color(0x00ffd1) as any,
|
||||
() => new Vector3(0, 0, 0) as any,
|
||||
() => {},
|
||||
{ distanceFromCamera: 40 }
|
||||
);
|
||||
const halo = scene.children[0] as any;
|
||||
const core = scene.children[1] as any;
|
||||
// mock applyQuaternion is identity, so startPos = camera.pos + (0,0,-40)
|
||||
expect(halo.position.x).toBeCloseTo(0);
|
||||
expect(halo.position.y).toBeCloseTo(30);
|
||||
expect(halo.position.z).toBeCloseTo(40); // 80 + (-40)
|
||||
expect(core.position.x).toBeCloseTo(halo.position.x);
|
||||
expect(core.position.y).toBeCloseTo(halo.position.y);
|
||||
expect(core.position.z).toBeCloseTo(halo.position.z);
|
||||
});
|
||||
|
||||
it('gestation phase: position stays at startPos for all 48 frames', () => {
|
||||
const cam = makeCamera();
|
||||
effects.createBirthOrb(
|
||||
cam,
|
||||
new Color(0x00ffd1) as any,
|
||||
() => new Vector3(100, 100, 100) as any, // far-away target
|
||||
() => {}
|
||||
);
|
||||
const halo = scene.children[0] as any;
|
||||
const startX = halo.position.x;
|
||||
const startY = halo.position.y;
|
||||
const startZ = halo.position.z;
|
||||
|
||||
for (let f = 0; f < 48; f++) {
|
||||
effects.update(nodeMeshMap, cam);
|
||||
expect(halo.position.x).toBeCloseTo(startX);
|
||||
expect(halo.position.y).toBeCloseTo(startY);
|
||||
expect(halo.position.z).toBeCloseTo(startZ);
|
||||
}
|
||||
});
|
||||
|
||||
it('gestation phase: opacity rises from 0 toward 0.95', () => {
|
||||
const cam = makeCamera();
|
||||
effects.createBirthOrb(
|
||||
cam,
|
||||
new Color(0x00ffd1) as any,
|
||||
() => new Vector3(0, 0, 0) as any,
|
||||
() => {}
|
||||
);
|
||||
const halo = scene.children[0] as any;
|
||||
const core = scene.children[1] as any;
|
||||
|
||||
// Spawn opacity
|
||||
expect(halo.material.opacity).toBe(0);
|
||||
expect(core.material.opacity).toBe(0);
|
||||
|
||||
effects.update(nodeMeshMap, cam); // age 1
|
||||
const earlyHaloOp = halo.material.opacity;
|
||||
expect(earlyHaloOp).toBeGreaterThan(0);
|
||||
expect(earlyHaloOp).toBeLessThan(0.2);
|
||||
|
||||
// Run to end of gestation
|
||||
for (let f = 0; f < 47; f++) effects.update(nodeMeshMap, cam);
|
||||
expect(halo.material.opacity).toBeCloseTo(0.95, 1);
|
||||
expect(core.material.opacity).toBeCloseTo(1.0, 1);
|
||||
// Monotonic-ish growth: late gestation > early gestation
|
||||
expect(halo.material.opacity).toBeGreaterThan(earlyHaloOp);
|
||||
});
|
||||
|
||||
it('gestation phase: sprite scale grows substantially', () => {
|
||||
const cam = makeCamera();
|
||||
effects.createBirthOrb(
|
||||
cam,
|
||||
new Color(0x00ffd1) as any,
|
||||
() => new Vector3(0, 0, 0) as any,
|
||||
() => {}
|
||||
);
|
||||
const halo = scene.children[0] as any;
|
||||
|
||||
effects.update(nodeMeshMap, cam); // age 1
|
||||
const earlyScale = halo.scale.x;
|
||||
|
||||
for (let f = 0; f < 47; f++) effects.update(nodeMeshMap, cam); // age 48
|
||||
const lateScale = halo.scale.x;
|
||||
|
||||
// Halo grows from ~0.5 toward ~5 during gestation (with pulse variation).
|
||||
expect(lateScale).toBeGreaterThan(earlyScale);
|
||||
expect(lateScale).toBeGreaterThan(2);
|
||||
});
|
||||
|
||||
it('gestation phase: halo color tints toward event color', () => {
|
||||
const cam = makeCamera();
|
||||
const eventColor = new Color(0xff0000); // pure red
|
||||
effects.createBirthOrb(
|
||||
cam,
|
||||
eventColor as any,
|
||||
() => new Vector3(0, 0, 0) as any,
|
||||
() => {}
|
||||
);
|
||||
const halo = scene.children[0] as any;
|
||||
|
||||
effects.update(nodeMeshMap, cam); // age 1 — factor ≈ 0.72
|
||||
const earlyR = halo.material.color.r;
|
||||
|
||||
for (let f = 0; f < 47; f++) effects.update(nodeMeshMap, cam); // age 48 — factor = 1.0
|
||||
const lateR = halo.material.color.r;
|
||||
|
||||
// Red channel should approach the event color's red (1.0) from a dimmer value
|
||||
expect(lateR).toBeGreaterThan(earlyR);
|
||||
expect(lateR).toBeCloseTo(1.0, 1);
|
||||
// Green/blue stay at 0 (event color is pure red)
|
||||
expect(halo.material.color.g).toBeCloseTo(0);
|
||||
expect(halo.material.color.b).toBeCloseTo(0);
|
||||
});
|
||||
|
||||
it('flight phase: Bezier arc passes ABOVE the linear midpoint at t=0.5', () => {
|
||||
const cam = makeCamera();
|
||||
// startPos = (0, 30, 40), target = (0, 0, 0)
|
||||
// linear midpoint y = 15; control point y = 15 + 30 + dist*0.15 = 52.5
|
||||
effects.createBirthOrb(
|
||||
cam,
|
||||
new Color(0x00ffd1) as any,
|
||||
() => new Vector3(0, 0, 0) as any,
|
||||
() => {}
|
||||
);
|
||||
const halo = scene.children[0] as any;
|
||||
|
||||
// Drive past gestation (48) + half of flight (45) = 93 frames → t=0.5
|
||||
for (let f = 0; f < 93; f++) effects.update(nodeMeshMap, cam);
|
||||
|
||||
// Linear midpoint y is 15; Bezier midpoint should be notably higher.
|
||||
expect(halo.position.y).toBeGreaterThan(15);
|
||||
// And not as high as the control point itself (52.5) — Bezier
|
||||
// passes through midpoint-ish at t=0.5, biased upward by the arc.
|
||||
expect(halo.position.y).toBeLessThan(52.5);
|
||||
});
|
||||
|
||||
it('flight phase: orb moves from startPos toward target', () => {
|
||||
const cam = makeCamera();
|
||||
effects.createBirthOrb(
|
||||
cam,
|
||||
new Color(0x00ffd1) as any,
|
||||
() => new Vector3(0, 0, 0) as any,
|
||||
() => {}
|
||||
);
|
||||
const halo = scene.children[0] as any;
|
||||
|
||||
// End of gestation
|
||||
for (let f = 0; f < 48; f++) effects.update(nodeMeshMap, cam);
|
||||
const gestZ = halo.position.z;
|
||||
|
||||
// One tick into flight
|
||||
effects.update(nodeMeshMap, cam);
|
||||
const earlyFlightZ = halo.position.z;
|
||||
|
||||
// Near end of flight
|
||||
for (let f = 0; f < 88; f++) effects.update(nodeMeshMap, cam);
|
||||
const lateFlightZ = halo.position.z;
|
||||
|
||||
// Z moves from 40 toward 0
|
||||
expect(earlyFlightZ).toBeLessThan(gestZ);
|
||||
expect(lateFlightZ).toBeLessThan(earlyFlightZ);
|
||||
expect(lateFlightZ).toBeLessThan(5); // close to target z=0
|
||||
});
|
||||
|
||||
it('dynamic target tracking: changing getTargetPos mid-flight redirects the orb', () => {
|
||||
const cam = makeCamera();
|
||||
let target = new Vector3(0, 0, 0);
|
||||
effects.createBirthOrb(
|
||||
cam,
|
||||
new Color(0x00ffd1) as any,
|
||||
() => target as any,
|
||||
() => {}
|
||||
);
|
||||
const halo = scene.children[0] as any;
|
||||
|
||||
// Drive to mid-flight (gestation 48 + 30 flight frames = 78)
|
||||
for (let f = 0; f < 78; f++) effects.update(nodeMeshMap, cam);
|
||||
const xBeforeRedirect = halo.position.x;
|
||||
|
||||
// Redirect target far to the +X side
|
||||
target = new Vector3(200, 0, 0);
|
||||
|
||||
// A few more flight frames — orb should track the new target
|
||||
for (let f = 0; f < 10; f++) effects.update(nodeMeshMap, cam);
|
||||
const xAfterRedirect = halo.position.x;
|
||||
|
||||
// With the original target at (0,0,0), x stays near 0 throughout.
|
||||
// After redirect, x should swing toward the new target's +200.
|
||||
expect(xAfterRedirect).toBeGreaterThan(xBeforeRedirect + 5);
|
||||
});
|
||||
|
||||
it('onArrive fires exactly once at frame 139 (totalFrames + 1)', () => {
|
||||
const cam = makeCamera();
|
||||
let arriveCount = 0;
|
||||
effects.createBirthOrb(
|
||||
cam,
|
||||
new Color(0x00ffd1) as any,
|
||||
() => new Vector3(0, 0, 0) as any,
|
||||
() => {
|
||||
arriveCount++;
|
||||
}
|
||||
);
|
||||
|
||||
// Drive through gestation (48) + flight (90) = 138 frames. Should NOT have fired.
|
||||
for (let f = 0; f < 138; f++) effects.update(nodeMeshMap, cam);
|
||||
expect(arriveCount).toBe(0);
|
||||
|
||||
// Frame 139 — fires onArrive
|
||||
effects.update(nodeMeshMap, cam);
|
||||
expect(arriveCount).toBe(1);
|
||||
|
||||
// Drive many more frames — must stay at 1
|
||||
for (let f = 0; f < 50; f++) effects.update(nodeMeshMap, cam);
|
||||
expect(arriveCount).toBe(1);
|
||||
});
|
||||
|
||||
it('post-arrival fade: orb disposes from scene after ~8 fade frames', () => {
|
||||
const cam = makeCamera();
|
||||
const baseline = scene.children.length;
|
||||
effects.createBirthOrb(
|
||||
cam,
|
||||
new Color(0x00ffd1) as any,
|
||||
() => new Vector3(0, 0, 0) as any,
|
||||
() => {}
|
||||
);
|
||||
expect(scene.children.length).toBe(baseline + 2);
|
||||
|
||||
// Gestation + flight + arrive + fade = 138 + 1 + 8 = 147 frames
|
||||
for (let f = 0; f < 150; f++) effects.update(nodeMeshMap, cam);
|
||||
|
||||
// Both orb sprites should be gone
|
||||
expect(scene.children.length).toBe(baseline);
|
||||
});
|
||||
|
||||
it('onArrive callback wrapped in try/catch so a throw does not crash the loop', () => {
|
||||
const cam = makeCamera();
|
||||
effects.createBirthOrb(
|
||||
cam,
|
||||
new Color(0x00ffd1) as any,
|
||||
() => new Vector3(0, 0, 0) as any,
|
||||
() => {
|
||||
throw new Error('caller blew up');
|
||||
}
|
||||
);
|
||||
|
||||
// Should not throw — the production code swallows arrival-callback errors.
|
||||
expect(() => {
|
||||
for (let f = 0; f < 160; f++) effects.update(nodeMeshMap, cam);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('Sanhedrin Shatter: onArrive NEVER fires when target vanishes mid-flight', () => {
|
||||
const cam = makeCamera();
|
||||
let arriveCount = 0;
|
||||
let target: Vector3 | undefined = new Vector3(0, 0, 0);
|
||||
|
||||
effects.createBirthOrb(
|
||||
cam,
|
||||
new Color(0x00ffd1) as any,
|
||||
() => target as any,
|
||||
() => {
|
||||
arriveCount++;
|
||||
}
|
||||
);
|
||||
|
||||
// Finish gestation (48 frames) with target present
|
||||
for (let f = 0; f < 48; f++) effects.update(nodeMeshMap, cam);
|
||||
expect(arriveCount).toBe(0);
|
||||
|
||||
// Stop hook yanks the target mid-flight
|
||||
target = undefined;
|
||||
|
||||
// Run enough frames to cover the entire orb lifecycle
|
||||
for (let f = 0; f < 200; f++) effects.update(nodeMeshMap, cam);
|
||||
|
||||
// onArrive must NEVER fire on aborted orbs
|
||||
expect(arriveCount).toBe(0);
|
||||
});
|
||||
|
||||
it('Sanhedrin Shatter: implosion is spawned when target vanishes mid-flight', () => {
|
||||
const cam = makeCamera();
|
||||
let target: Vector3 | undefined = new Vector3(0, 0, 0);
|
||||
|
||||
const baseline = scene.children.length;
|
||||
effects.createBirthOrb(
|
||||
cam,
|
||||
new Color(0x00ffd1) as any,
|
||||
() => target as any,
|
||||
() => {}
|
||||
);
|
||||
// baseline + 2 sprites
|
||||
expect(scene.children.length).toBe(baseline + 2);
|
||||
|
||||
// Finish gestation
|
||||
for (let f = 0; f < 48; f++) effects.update(nodeMeshMap, cam);
|
||||
|
||||
// Yank target → abort triggers on next tick
|
||||
target = undefined;
|
||||
const beforeAbort = scene.children.length;
|
||||
effects.update(nodeMeshMap, cam);
|
||||
// Scene should have grown by at least 1 (the implosion particles)
|
||||
expect(scene.children.length).toBeGreaterThan(beforeAbort);
|
||||
});
|
||||
|
||||
it('Sanhedrin Shatter: halo turns blood-red on abort', () => {
|
||||
const cam = makeCamera();
|
||||
let target: Vector3 | undefined = new Vector3(0, 0, 0);
|
||||
|
||||
effects.createBirthOrb(
|
||||
cam,
|
||||
new Color(0x00ffd1) as any, // cyan — NOT red
|
||||
() => target as any,
|
||||
() => {}
|
||||
);
|
||||
const halo = scene.children[0] as any;
|
||||
|
||||
// Finish gestation
|
||||
for (let f = 0; f < 48; f++) effects.update(nodeMeshMap, cam);
|
||||
|
||||
// Sanity: halo is NOT red yet (event color cyan has r≈0)
|
||||
expect(halo.material.color.r).toBeLessThan(0.5);
|
||||
|
||||
// Yank target; abort triggers next tick
|
||||
target = undefined;
|
||||
effects.update(nodeMeshMap, cam);
|
||||
|
||||
// Halo should now be blood red (1.0, 0.15, 0.2)
|
||||
expect(halo.material.color.r).toBeGreaterThan(0.9);
|
||||
expect(halo.material.color.g).toBeLessThan(0.3);
|
||||
expect(halo.material.color.b).toBeLessThan(0.3);
|
||||
});
|
||||
|
||||
it('Sanhedrin Shatter: orb eventually disposes from scene', () => {
|
||||
const cam = makeCamera();
|
||||
let target: Vector3 | undefined = new Vector3(0, 0, 0);
|
||||
|
||||
const baseline = scene.children.length;
|
||||
effects.createBirthOrb(
|
||||
cam,
|
||||
new Color(0x00ffd1) as any,
|
||||
() => target as any,
|
||||
() => {}
|
||||
);
|
||||
|
||||
// Finish gestation
|
||||
for (let f = 0; f < 48; f++) effects.update(nodeMeshMap, cam);
|
||||
// Yank target
|
||||
target = undefined;
|
||||
|
||||
// Drive a long time — orb + implosion should both dispose
|
||||
// (orb fade ~8 frames, implosion lifetime ~80 frames)
|
||||
for (let f = 0; f < 200; f++) effects.update(nodeMeshMap, cam);
|
||||
|
||||
expect(scene.children.length).toBe(baseline);
|
||||
});
|
||||
|
||||
it('dispose() removes active birth orbs from the scene', () => {
|
||||
const cam = makeCamera();
|
||||
effects.createBirthOrb(
|
||||
cam,
|
||||
new Color(0x00ffd1) as any,
|
||||
() => new Vector3(0, 0, 0) as any,
|
||||
() => {}
|
||||
);
|
||||
effects.createBirthOrb(
|
||||
cam,
|
||||
new Color(0xff00ff) as any,
|
||||
() => new Vector3(10, 10, 10) as any,
|
||||
() => {}
|
||||
);
|
||||
// 4 sprites in scene (2 per orb)
|
||||
expect(scene.children.length).toBeGreaterThanOrEqual(4);
|
||||
|
||||
effects.dispose();
|
||||
|
||||
// All orb sprites should be gone
|
||||
expect(scene.children.length).toBe(0);
|
||||
});
|
||||
|
||||
it('multiple orbs in flight: all 3 onArrive callbacks fire exactly once each', () => {
|
||||
const cam = makeCamera();
|
||||
let c1 = 0, c2 = 0, c3 = 0;
|
||||
|
||||
effects.createBirthOrb(
|
||||
cam,
|
||||
new Color(0xff0000) as any,
|
||||
() => new Vector3(10, 0, 0) as any,
|
||||
() => { c1++; }
|
||||
);
|
||||
effects.createBirthOrb(
|
||||
cam,
|
||||
new Color(0x00ff00) as any,
|
||||
() => new Vector3(-10, 0, 0) as any,
|
||||
() => { c2++; }
|
||||
);
|
||||
effects.createBirthOrb(
|
||||
cam,
|
||||
new Color(0x0000ff) as any,
|
||||
() => new Vector3(0, 0, -10) as any,
|
||||
() => { c3++; }
|
||||
);
|
||||
|
||||
// Drive past arrival (139) with margin
|
||||
for (let f = 0; f < 160; f++) effects.update(nodeMeshMap, cam);
|
||||
|
||||
expect(c1).toBe(1);
|
||||
expect(c2).toBe(1);
|
||||
expect(c3).toBe(1);
|
||||
});
|
||||
|
||||
it('custom gestation/flight frame counts are honored', () => {
|
||||
const cam = makeCamera();
|
||||
let arriveCount = 0;
|
||||
effects.createBirthOrb(
|
||||
cam,
|
||||
new Color(0x00ffd1) as any,
|
||||
() => new Vector3(0, 0, 0) as any,
|
||||
() => { arriveCount++; },
|
||||
{ gestationFrames: 10, flightFrames: 20 }
|
||||
);
|
||||
|
||||
// Before frame 31 — no arrival
|
||||
for (let f = 0; f < 30; f++) effects.update(nodeMeshMap, cam);
|
||||
expect(arriveCount).toBe(0);
|
||||
|
||||
// Frame 31 — fires
|
||||
effects.update(nodeMeshMap, cam);
|
||||
expect(arriveCount).toBe(1);
|
||||
});
|
||||
|
||||
it('zero-alloc invariant (advisory): flight phase runs without throwing across many orbs', () => {
|
||||
// Advisory test — vitest has no allocator introspection, but the
|
||||
// inline algebraic Bezier eval in effects.ts is intentionally zero-
|
||||
// allocation per frame (no `new Vector3`, no `new QuadraticBezierCurve3`).
|
||||
// Here we just smoke-test that running many orbs across the full
|
||||
// flight phase does not throw and completes cleanly.
|
||||
const cam = makeCamera();
|
||||
for (let k = 0; k < 6; k++) {
|
||||
effects.createBirthOrb(
|
||||
cam,
|
||||
new Color(0x00ffd1) as any,
|
||||
() => new Vector3(k * 5, 0, 0) as any,
|
||||
() => {}
|
||||
);
|
||||
}
|
||||
expect(() => {
|
||||
for (let f = 0; f < 150; f++) effects.update(nodeMeshMap, cam);
|
||||
}).not.toThrow();
|
||||
// All orbs should have cleaned up
|
||||
expect(scene.children.length).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { NodeManager } from '../nodes';
|
|||
import { EdgeManager } from '../edges';
|
||||
import { EffectManager } from '../effects';
|
||||
import { ForceSimulation } from '../force-sim';
|
||||
import { Vector3, Scene } from './three-mock';
|
||||
import { Vector3, Scene, RingGeometry, Mesh, Points, Sprite } from './three-mock';
|
||||
import { makeNode, makeEdge, makeEvent, resetNodeCounter } from './helpers';
|
||||
import type { GraphNode, VestigeEvent } from '$types';
|
||||
|
||||
|
|
@ -155,7 +155,7 @@ describe('Event-to-Mutation Pipeline', () => {
|
|||
expect(distToN1).toBeLessThan(20);
|
||||
});
|
||||
|
||||
it('triggers rainbow burst effect', () => {
|
||||
it('spawns a v2.3 birth orb in the scene', () => {
|
||||
const childrenBefore = scene.children.length;
|
||||
|
||||
mapEventToEffects(
|
||||
|
|
@ -168,16 +168,19 @@ describe('Event-to-Mutation Pipeline', () => {
|
|||
allNodes
|
||||
);
|
||||
|
||||
// Scene should have new particles (rainbow burst + shockwave + possibly more)
|
||||
expect(scene.children.length).toBeGreaterThan(childrenBefore);
|
||||
// Birth orb adds a halo sprite + bright core sprite to the scene
|
||||
// immediately. The arrival-cascade effects (rainbow burst, shockwaves,
|
||||
// ripple wave) are deferred to the orb's onArrive callback — covered
|
||||
// by the "fires arrival cascade after ritual" test below.
|
||||
expect(scene.children.length).toBeGreaterThanOrEqual(childrenBefore + 2);
|
||||
});
|
||||
|
||||
it('triggers double shockwave (second delayed)', () => {
|
||||
it('fires the arrival cascade after the birth ritual completes', () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
mapEventToEffects(
|
||||
makeEvent('MemoryCreated', {
|
||||
id: 'double-shock',
|
||||
id: 'cascade-check',
|
||||
content: 'test',
|
||||
node_type: 'fact',
|
||||
}),
|
||||
|
|
@ -185,13 +188,23 @@ describe('Event-to-Mutation Pipeline', () => {
|
|||
allNodes
|
||||
);
|
||||
|
||||
const initialChildren = scene.children.length;
|
||||
const afterSpawn = scene.children.length;
|
||||
|
||||
// Advance past the setTimeout
|
||||
vi.advanceTimersByTime(200);
|
||||
// Drive the effects update loop past the full ritual duration
|
||||
// (gestation 48 + flight 90 = 138 frames). Each tick is one frame;
|
||||
// we run 150 to give onArrive room to fire.
|
||||
for (let i = 0; i < 150; i++) {
|
||||
effects.update(nodeManager.meshMap, camera, nodeManager.positions);
|
||||
}
|
||||
|
||||
// Second shockwave should have been added
|
||||
expect(scene.children.length).toBeGreaterThan(initialChildren);
|
||||
// Advance the setTimeout that schedules the delayed second shockwave.
|
||||
vi.advanceTimersByTime(250);
|
||||
|
||||
// The arrival cascade should have added a rainbow burst, shockwave,
|
||||
// ripple wave, and delayed second shockwave to the scene. Even after
|
||||
// the orb fades out and is removed, the burst particles persist long
|
||||
// enough that children.length should exceed the post-spawn count.
|
||||
expect(scene.children.length).toBeGreaterThan(afterSpawn);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
|
@ -861,4 +874,270 @@ describe('Event-to-Mutation Pipeline', () => {
|
|||
expect(mutations.some((m) => m.type === 'edgeAdded')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('v2.3 Birth Ritual wiring', () => {
|
||||
/** Count shockwave rings currently in the scene by their RingGeometry. */
|
||||
function countRings(s: InstanceType<typeof Scene>): number {
|
||||
let n = 0;
|
||||
for (const child of s.children) {
|
||||
if (child instanceof Mesh && child.geometry instanceof RingGeometry) n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/** Count Points children — rainbow bursts, spawn bursts, implosions. */
|
||||
function countPoints(s: InstanceType<typeof Scene>): number {
|
||||
let n = 0;
|
||||
for (const child of s.children) if (child instanceof Points) n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
/** Count Sprite children — birth orb adds a halo + core sprite. */
|
||||
function countSprites(s: InstanceType<typeof Scene>): number {
|
||||
let n = 0;
|
||||
for (const child of s.children) if (child instanceof Sprite) n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
it('node mesh is hidden immediately after MemoryCreated dispatch', () => {
|
||||
mapEventToEffects(
|
||||
makeEvent('MemoryCreated', {
|
||||
id: 'ritual-create',
|
||||
content: 'fresh memory',
|
||||
node_type: 'fact',
|
||||
}),
|
||||
ctx,
|
||||
allNodes
|
||||
);
|
||||
|
||||
// Ritual path: mesh/glow/label are all .visible = false until
|
||||
// igniteNode fires on orb arrival.
|
||||
const mesh = nodeManager.meshMap.get('ritual-create')!;
|
||||
const glow = nodeManager.glowMap.get('ritual-create')!;
|
||||
const label = nodeManager.labelSprites.get('ritual-create')!;
|
||||
expect(mesh.visible).toBe(false);
|
||||
expect(glow.visible).toBe(false);
|
||||
expect(label.visible).toBe(false);
|
||||
|
||||
// Pending sentinel is stamped on userData.
|
||||
expect(mesh.userData.birthRitualPending).toBeDefined();
|
||||
});
|
||||
|
||||
it('does NOT fire burst/ripple/shockwave at spawn (only the birth orb)', () => {
|
||||
const ringsBefore = countRings(scene);
|
||||
const pointsBefore = countPoints(scene);
|
||||
const spritesBefore = countSprites(scene);
|
||||
|
||||
mapEventToEffects(
|
||||
makeEvent('MemoryCreated', {
|
||||
id: 'spawn-quiet',
|
||||
content: 'test',
|
||||
node_type: 'fact',
|
||||
}),
|
||||
ctx,
|
||||
allNodes
|
||||
);
|
||||
|
||||
// Birth orb adds exactly 2 sprites (halo + core). NodeManager's
|
||||
// addNode also adds a glow Sprite + label Sprite to the NodeManager
|
||||
// GROUP, not to the scene — so spritesBefore -> after delta is +2.
|
||||
expect(countSprites(scene) - spritesBefore).toBe(2);
|
||||
|
||||
// No arrival-cascade effects yet: no shockwave rings, no rainbow
|
||||
// burst/spawn burst/ripple particles.
|
||||
expect(countRings(scene)).toBe(ringsBefore);
|
||||
expect(countPoints(scene)).toBe(pointsBefore);
|
||||
});
|
||||
|
||||
it('drives through the full ritual: onArrive fires, node becomes visible, scale grows', () => {
|
||||
mapEventToEffects(
|
||||
makeEvent('MemoryCreated', {
|
||||
id: 'full-ritual',
|
||||
content: 'visible after arrival',
|
||||
node_type: 'fact',
|
||||
}),
|
||||
ctx,
|
||||
allNodes
|
||||
);
|
||||
|
||||
const mesh = nodeManager.meshMap.get('full-ritual')!;
|
||||
expect(mesh.visible).toBe(false);
|
||||
|
||||
// Drive the effects update loop past the full ritual duration
|
||||
// (gestation 48 + flight 90 = 138 frames). After frame 138 the
|
||||
// orb fires onArrive which ignites the node and queues materialization.
|
||||
for (let i = 0; i < 140; i++) {
|
||||
effects.update(nodeManager.meshMap, camera, nodeManager.positions);
|
||||
}
|
||||
|
||||
// Node is now visible and sentinel is cleared.
|
||||
expect(mesh.visible).toBe(true);
|
||||
expect(mesh.userData.birthRitualPending).toBeUndefined();
|
||||
|
||||
// Run node animation a few frames to let materialization scale grow.
|
||||
// Note: onArrive bumped scale by 1.8x (from 0.001 -> 0.0018), then
|
||||
// materialization easeOutElastic pulls it toward targetScale.
|
||||
for (let f = 0; f < 10; f++) {
|
||||
nodeManager.animate(f * 0.016, allNodes, camera);
|
||||
}
|
||||
expect(mesh.scale.x).toBeGreaterThan(0.001);
|
||||
});
|
||||
|
||||
it("Newton's Cradle — target mesh scale is multiplied by 1.8x on arrival", () => {
|
||||
mapEventToEffects(
|
||||
makeEvent('MemoryCreated', {
|
||||
id: 'newton-cradle',
|
||||
content: 'recoil test',
|
||||
node_type: 'fact',
|
||||
}),
|
||||
ctx,
|
||||
allNodes
|
||||
);
|
||||
|
||||
const mesh = nodeManager.meshMap.get('newton-cradle')!;
|
||||
// Pre-arrival: scale is the addNode initial 0.001.
|
||||
expect(mesh.scale.x).toBeCloseTo(0.001, 6);
|
||||
|
||||
// Drive just to the moment onArrive fires. Gestation (48) +
|
||||
// flight (90) = 138 frames. Arrival bumps scale by 1.8x BEFORE
|
||||
// materialization has run any ticks, so the scale should be
|
||||
// exactly 0.001 * 1.8 = 0.0018 at that instant. We check right
|
||||
// after onArrive (frame 139) — but effects.update progresses the
|
||||
// orb's age counter by one each call, and on the tick where
|
||||
// orb.age > totalFrames, onArrive fires. We then must NOT tick
|
||||
// nodeManager.animate (or materialization would diverge the scale).
|
||||
for (let i = 0; i < 140; i++) {
|
||||
effects.update(nodeManager.meshMap, camera, nodeManager.positions);
|
||||
}
|
||||
|
||||
// onArrive fired. Scale was 0.001, got multiplied by 1.8 -> 0.0018.
|
||||
// Materialization is queued but hasn't run yet (no animate() calls).
|
||||
expect(mesh.scale.x).toBeCloseTo(0.0018, 6);
|
||||
});
|
||||
|
||||
it('dual shockwave — arrival cascade adds TWO RingGeometry meshes, not one', () => {
|
||||
mapEventToEffects(
|
||||
makeEvent('MemoryCreated', {
|
||||
id: 'dual-shock',
|
||||
content: 'layered crash',
|
||||
node_type: 'fact',
|
||||
}),
|
||||
ctx,
|
||||
allNodes
|
||||
);
|
||||
|
||||
const ringsBefore = countRings(scene);
|
||||
|
||||
// Drive past full ritual so onArrive fires.
|
||||
for (let i = 0; i < 140; i++) {
|
||||
effects.update(nodeManager.meshMap, camera, nodeManager.positions);
|
||||
}
|
||||
|
||||
// Both shockwaves fire synchronously in the onArrive callback
|
||||
// (the previous setTimeout-delayed second shockwave was dropped
|
||||
// because it could outlive the scene on route change).
|
||||
const ringsAfter = countRings(scene);
|
||||
expect(ringsAfter - ringsBefore).toBe(2);
|
||||
});
|
||||
|
||||
it('re-reads position on arrival — fires cascade at force-sim-moved position', () => {
|
||||
mapEventToEffects(
|
||||
makeEvent('MemoryCreated', {
|
||||
id: 'moving-target',
|
||||
content: 'follow the node',
|
||||
node_type: 'fact',
|
||||
}),
|
||||
ctx,
|
||||
allNodes
|
||||
);
|
||||
|
||||
// Grab the spawn position, then mutate it to simulate the force
|
||||
// simulation pushing the node during the ritual.
|
||||
const movedPos = new Vector3(123, 456, -789);
|
||||
nodeManager.positions.set('moving-target', movedPos);
|
||||
|
||||
// Drive past full ritual.
|
||||
for (let i = 0; i < 140; i++) {
|
||||
effects.update(nodeManager.meshMap, camera, nodeManager.positions);
|
||||
}
|
||||
|
||||
// The onArrive callback re-reads nodeManager.positions and fires
|
||||
// the cascade at the LIVE position. The two shockwave Ring meshes
|
||||
// should have been created at movedPos. Find them and check.
|
||||
const rings = scene.children.filter(
|
||||
(c) => c instanceof Mesh && c.geometry instanceof RingGeometry
|
||||
);
|
||||
expect(rings.length).toBeGreaterThanOrEqual(2);
|
||||
// Rings for this node: their .position copies from arrivePos at
|
||||
// spawn time inside createShockwave.
|
||||
const atMovedPos = rings.filter(
|
||||
(r) => r.position.x === 123 && r.position.y === 456 && r.position.z === -789
|
||||
);
|
||||
expect(atMovedPos.length).toBe(2);
|
||||
});
|
||||
|
||||
it('Sanhedrin abort path — removeNode before arrival prevents the regular cascade', () => {
|
||||
// Spy on the three arrival-cascade emitters so we can assert
|
||||
// they were NEVER called when the target is vetoed mid-ritual.
|
||||
const burstSpy = vi.spyOn(effects, 'createRainbowBurst');
|
||||
const shockwaveSpy = vi.spyOn(effects, 'createShockwave');
|
||||
const rippleSpy = vi.spyOn(effects, 'createRippleWave');
|
||||
|
||||
mapEventToEffects(
|
||||
makeEvent('MemoryCreated', {
|
||||
id: 'vetoed',
|
||||
content: 'about to be shattered',
|
||||
node_type: 'fact',
|
||||
}),
|
||||
ctx,
|
||||
allNodes
|
||||
);
|
||||
|
||||
// The orb's getTargetPos() closure reads
|
||||
// nodeManager.positions.get('vetoed'). Dropping the position
|
||||
// directly simulates the "target gone" state that the Sanhedrin
|
||||
// veto produces after dissolution completes — without needing to
|
||||
// drive the full 60-frame dissolution animation.
|
||||
nodeManager.positions.delete('vetoed');
|
||||
expect(nodeManager.positions.has('vetoed')).toBe(false);
|
||||
|
||||
// Snapshot the orb reference before the update loop disposes it.
|
||||
// The abort branch flips `aborted` and tints the halo red; we
|
||||
// assert on those fields after the ritual unwinds.
|
||||
const orbs = (effects as any).birthOrbs as Array<{
|
||||
sprite: { material: { color: any } };
|
||||
core: { material: { color: any } };
|
||||
aborted: boolean;
|
||||
}>;
|
||||
expect(orbs.length).toBe(1);
|
||||
const orbRef = orbs[0];
|
||||
|
||||
// Drive effects past the full ritual. During flight the orb will
|
||||
// see getTargetPos() === undefined, enter the Sanhedrin branch,
|
||||
// call createImplosion (anti-birth visual) and SKIP onArrive —
|
||||
// so the regular rainbow-burst + dual-shockwave + ripple cascade
|
||||
// never fires.
|
||||
for (let i = 0; i < 200; i++) {
|
||||
effects.update(nodeManager.meshMap, camera, nodeManager.positions);
|
||||
}
|
||||
|
||||
// Core assertion: the three regular-cascade emitters were never
|
||||
// invoked for the vetoed node.
|
||||
expect(burstSpy).not.toHaveBeenCalled();
|
||||
expect(shockwaveSpy).not.toHaveBeenCalled();
|
||||
expect(rippleSpy).not.toHaveBeenCalled();
|
||||
|
||||
// Also confirm the orb actually took the abort branch, not the
|
||||
// gestation-only no-op path (otherwise this test would pass for
|
||||
// the wrong reason). The aborted flag is set exactly once inside
|
||||
// the Sanhedrin branch.
|
||||
expect(orbRef.aborted).toBe(true);
|
||||
expect(orbRef.sprite.material.color.r).toBeCloseTo(1.0, 3);
|
||||
expect(orbRef.sprite.material.color.g).toBeCloseTo(0.15, 3);
|
||||
|
||||
burstSpy.mockRestore();
|
||||
shockwaveSpy.mockRestore();
|
||||
rippleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -453,4 +453,201 @@ describe('NodeManager', () => {
|
|||
// The dispose method clears materializingNodes, dissolvingNodes, growingNodes
|
||||
});
|
||||
});
|
||||
|
||||
describe('Birth Ritual integration', () => {
|
||||
it('addNode with isBirthRitual:true hides mesh, glow, and label immediately', () => {
|
||||
const node = makeNode({ id: 'ritual-1' });
|
||||
manager.addNode(node, new Vector3(5, 5, 5), { isBirthRitual: true });
|
||||
|
||||
const mesh = manager.meshMap.get('ritual-1')!;
|
||||
const glow = manager.glowMap.get('ritual-1')!;
|
||||
const label = manager.labelSprites.get('ritual-1')!;
|
||||
|
||||
expect(mesh.visible).toBe(false);
|
||||
expect(glow.visible).toBe(false);
|
||||
expect(label.visible).toBe(false);
|
||||
});
|
||||
|
||||
it('addNode with isBirthRitual:true stores a pending sentinel on mesh.userData', () => {
|
||||
const node = makeNode({ id: 'ritual-sentinel', retention: 0.75 });
|
||||
manager.addNode(node, new Vector3(0, 0, 0), { isBirthRitual: true });
|
||||
|
||||
const mesh = manager.meshMap.get('ritual-sentinel')!;
|
||||
const pending = mesh.userData.birthRitualPending as any;
|
||||
expect(pending).toBeDefined();
|
||||
expect(pending.totalFrames).toBe(30);
|
||||
// targetScale = 0.5 + retention * 2 = 0.5 + 0.75 * 2 = 2.0
|
||||
expect(pending.targetScale).toBeCloseTo(2.0, 3);
|
||||
});
|
||||
|
||||
it('addNode with isBirthRitual:true does NOT enqueue materialization', () => {
|
||||
const ritualNode = makeNode({ id: 'ritual-pending', retention: 0.8 });
|
||||
manager.addNode(ritualNode, new Vector3(10, 10, 10), { isBirthRitual: true });
|
||||
|
||||
// In the real runtime the ritual-pending node is .visible=false
|
||||
// AND is not yet in the GraphNode[] list — it only gets added to
|
||||
// the visible node list once igniteNode flips its visibility and
|
||||
// materialization kicks in. So we pass an empty `nodes` array to
|
||||
// animate(), which also exercises that the breathing loop skips
|
||||
// meshes absent from the nodes array.
|
||||
const camera = { position: new Vector3(0, 30, 80) } as any;
|
||||
for (let f = 0; f < 40; f++) {
|
||||
manager.animate(f * 0.016, [], camera);
|
||||
}
|
||||
|
||||
const mesh = manager.meshMap.get('ritual-pending')!;
|
||||
// Materialization queue never pushed — a regular materializing
|
||||
// node would be at scale ≈ targetScale = 2.1 by frame 40. The
|
||||
// ritual-pending node stays at its addNode initial 0.001 because
|
||||
// no animation loop is mutating its scale.
|
||||
expect(mesh.scale.x).toBeCloseTo(0.001, 3);
|
||||
|
||||
// Stronger invariant — the sentinel is still there, confirming
|
||||
// the node never got handed off to the materialization queue.
|
||||
expect(mesh.userData.birthRitualPending).toBeDefined();
|
||||
});
|
||||
|
||||
it('addNode without opts proceeds with normal materialization (old behavior)', () => {
|
||||
const node = makeNode({ id: 'normal-spawn' });
|
||||
manager.addNode(node, new Vector3(1, 2, 3));
|
||||
|
||||
const mesh = manager.meshMap.get('normal-spawn')!;
|
||||
const glow = manager.glowMap.get('normal-spawn')!;
|
||||
const label = manager.labelSprites.get('normal-spawn')!;
|
||||
|
||||
// Default mesh.visible is true in three-mock (Object3D has no explicit field).
|
||||
// Key invariant: visible is NOT explicitly false like the ritual path.
|
||||
expect(mesh.visible).not.toBe(false);
|
||||
expect(glow.visible).not.toBe(false);
|
||||
expect(label.visible).not.toBe(false);
|
||||
|
||||
// And no pending sentinel
|
||||
expect(mesh.userData.birthRitualPending).toBeUndefined();
|
||||
|
||||
// Animation should proceed — scale grows via easeOutElastic
|
||||
const camera = { position: new Vector3(0, 30, 80) } as any;
|
||||
for (let f = 0; f < 20; f++) {
|
||||
manager.animate(f * 0.016, [node], camera);
|
||||
}
|
||||
expect(mesh.scale.x).toBeGreaterThan(0.1);
|
||||
});
|
||||
|
||||
it('igniteNode flips all three visibility flags and queues materialization', () => {
|
||||
const node = makeNode({ id: 'to-ignite', retention: 0.6 });
|
||||
manager.addNode(node, new Vector3(0, 0, 0), { isBirthRitual: true });
|
||||
|
||||
// Pre-ignite: hidden
|
||||
const mesh = manager.meshMap.get('to-ignite')!;
|
||||
const glow = manager.glowMap.get('to-ignite')!;
|
||||
const label = manager.labelSprites.get('to-ignite')!;
|
||||
expect(mesh.visible).toBe(false);
|
||||
|
||||
manager.igniteNode('to-ignite');
|
||||
|
||||
// Post-ignite: visible
|
||||
expect(mesh.visible).toBe(true);
|
||||
expect(glow.visible).toBe(true);
|
||||
expect(label.visible).toBe(true);
|
||||
|
||||
// Sentinel is gone
|
||||
expect(mesh.userData.birthRitualPending).toBeUndefined();
|
||||
|
||||
// Materialization was queued — drive animation and the scale
|
||||
// should grow past the initial 0.001.
|
||||
const camera = { position: new Vector3(0, 30, 80) } as any;
|
||||
for (let f = 0; f < 15; f++) {
|
||||
manager.animate(f * 0.016, [node], camera);
|
||||
}
|
||||
expect(mesh.scale.x).toBeGreaterThan(0.1);
|
||||
});
|
||||
|
||||
it('igniteNode called twice is idempotent (second call is a no-op)', () => {
|
||||
const node = makeNode({ id: 'double-ignite', retention: 0.5 });
|
||||
manager.addNode(node, new Vector3(0, 0, 0), { isBirthRitual: true });
|
||||
|
||||
manager.igniteNode('double-ignite');
|
||||
// Capture scale after one round of animation
|
||||
const camera = { position: new Vector3(0, 30, 80) } as any;
|
||||
for (let f = 0; f < 10; f++) {
|
||||
manager.animate(f * 0.016, [node], camera);
|
||||
}
|
||||
const scaleAfterFirst = manager.meshMap.get('double-ignite')!.scale.x;
|
||||
|
||||
// Second ignite — should NOT push a duplicate materialization entry.
|
||||
// If it did, the extra entry (starting at frame 0) would restart
|
||||
// the scale back near 0.001 or at least visibly reset it.
|
||||
manager.igniteNode('double-ignite');
|
||||
for (let f = 0; f < 5; f++) {
|
||||
manager.animate((f + 10) * 0.016, [node], camera);
|
||||
}
|
||||
const scaleAfterSecond = manager.meshMap.get('double-ignite')!.scale.x;
|
||||
|
||||
// Scale after second ignite should be greater than or roughly equal
|
||||
// to scale after first, NOT reset toward 0.001. A duplicate entry
|
||||
// starting at frame 0 would pull the mesh back near zero on the
|
||||
// very first subsequent animate() tick via mn.mesh.scale.setScalar.
|
||||
expect(scaleAfterSecond).toBeGreaterThanOrEqual(scaleAfterFirst * 0.5);
|
||||
});
|
||||
|
||||
it('igniteNode on a regular (non-ritual) node is a no-op', () => {
|
||||
const node = makeNode({ id: 'regular', retention: 0.5 });
|
||||
manager.addNode(node, new Vector3(0, 0, 0));
|
||||
// Regular addNode already queued materialization. Capture state.
|
||||
const mesh = manager.meshMap.get('regular')!;
|
||||
const visBefore = mesh.visible;
|
||||
|
||||
// Call igniteNode — there's no pending sentinel, should short-circuit.
|
||||
expect(() => manager.igniteNode('regular')).not.toThrow();
|
||||
|
||||
// No pending sentinel means the function returns early after the
|
||||
// sentinel check, so nothing about the mesh changes.
|
||||
expect(mesh.visible).toBe(visBefore);
|
||||
expect(mesh.userData.birthRitualPending).toBeUndefined();
|
||||
});
|
||||
|
||||
it('igniteNode on unknown id is a no-op (no throw)', () => {
|
||||
expect(() => manager.igniteNode('does-not-exist')).not.toThrow();
|
||||
expect(manager.meshMap.has('does-not-exist')).toBe(false);
|
||||
});
|
||||
|
||||
it('position is stored in positions map even when the node is invisible', () => {
|
||||
const node = makeNode({ id: 'invisible-but-positioned' });
|
||||
const spawnPos = new Vector3(42, -17, 8);
|
||||
manager.addNode(node, spawnPos, { isBirthRitual: true });
|
||||
|
||||
// Force simulation + orb getTargetPos() both rely on positions
|
||||
// being live immediately — the ritual only hides visuals, not
|
||||
// physics state.
|
||||
const stored = manager.positions.get('invisible-but-positioned');
|
||||
expect(stored).toBeDefined();
|
||||
expect(stored!.x).toBe(42);
|
||||
expect(stored!.y).toBe(-17);
|
||||
expect(stored!.z).toBe(8);
|
||||
|
||||
// And the mesh itself is still hidden
|
||||
expect(manager.meshMap.get('invisible-but-positioned')!.visible).toBe(false);
|
||||
});
|
||||
|
||||
it('removeNode during pending ritual cancels without materialization', () => {
|
||||
// Sanhedrin abort path at the NodeManager level: a ritual-pending
|
||||
// node gets removed before igniteNode fires. The remove path
|
||||
// should still work (dissolution queue takes over) and igniteNode
|
||||
// called later must not resurrect it.
|
||||
const node = makeNode({ id: 'aborted-ritual' });
|
||||
manager.addNode(node, new Vector3(0, 0, 0), { isBirthRitual: true });
|
||||
|
||||
manager.removeNode('aborted-ritual');
|
||||
|
||||
// Dissolution progresses past totalFrames = 60 and clears state.
|
||||
const camera = { position: new Vector3(0, 30, 80) } as any;
|
||||
for (let f = 0; f < 65; f++) {
|
||||
manager.animate(f * 0.016, [node], camera);
|
||||
}
|
||||
|
||||
expect(manager.meshMap.has('aborted-ritual')).toBe(false);
|
||||
|
||||
// And a late igniteNode call on the dead id is a safe no-op.
|
||||
expect(() => manager.igniteNode('aborted-ritual')).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -93,6 +93,52 @@ export class Vector3 {
|
|||
this.z = s;
|
||||
return this;
|
||||
}
|
||||
|
||||
addVectors(a: Vector3, b: Vector3) {
|
||||
this.x = a.x + b.x;
|
||||
this.y = a.y + b.y;
|
||||
this.z = a.z + b.z;
|
||||
return this;
|
||||
}
|
||||
|
||||
applyQuaternion(_q: Quaternion) {
|
||||
// Mock: identity transform. Tests don't care about actual
|
||||
// camera-relative positioning; production uses real THREE math.
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
export class Quaternion {
|
||||
x = 0;
|
||||
y = 0;
|
||||
z = 0;
|
||||
w = 1;
|
||||
}
|
||||
|
||||
export class QuadraticBezierCurve3 {
|
||||
v0: Vector3;
|
||||
v1: Vector3;
|
||||
v2: Vector3;
|
||||
constructor(v0: Vector3, v1: Vector3, v2: Vector3) {
|
||||
this.v0 = v0;
|
||||
this.v1 = v1;
|
||||
this.v2 = v2;
|
||||
}
|
||||
getPoint(t: number): Vector3 {
|
||||
// Standard quadratic Bezier evaluation, faithful enough for tests
|
||||
// that only care that points land on the curve.
|
||||
const one = 1 - t;
|
||||
return new Vector3(
|
||||
one * one * this.v0.x + 2 * one * t * this.v1.x + t * t * this.v2.x,
|
||||
one * one * this.v0.y + 2 * one * t * this.v1.y + t * t * this.v2.y,
|
||||
one * one * this.v0.z + 2 * one * t * this.v1.z + t * t * this.v2.z
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class Texture {
|
||||
needsUpdate = false;
|
||||
dispose() {}
|
||||
}
|
||||
|
||||
export class Vector2 {
|
||||
|
|
@ -157,6 +203,20 @@ export class Color {
|
|||
offsetHSL(_h: number, _s: number, _l: number) {
|
||||
return this;
|
||||
}
|
||||
|
||||
multiplyScalar(s: number) {
|
||||
this.r *= s;
|
||||
this.g *= s;
|
||||
this.b *= s;
|
||||
return this;
|
||||
}
|
||||
|
||||
setRGB(r: number, g: number, b: number) {
|
||||
this.r = r;
|
||||
this.g = g;
|
||||
this.b = b;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
export class BufferAttribute {
|
||||
|
|
@ -329,6 +389,8 @@ export class SpriteMaterial extends BaseMaterial {
|
|||
export class Object3D {
|
||||
position = new Vector3();
|
||||
scale = new Vector3(1, 1, 1);
|
||||
quaternion = new Quaternion();
|
||||
renderOrder = 0;
|
||||
userData: Record<string, unknown> = {};
|
||||
children: Object3D[] = [];
|
||||
parent: Object3D | null = null;
|
||||
|
|
@ -428,6 +490,9 @@ export function installThreeMock() {
|
|||
Vector3,
|
||||
Vector2,
|
||||
Color,
|
||||
Quaternion,
|
||||
QuadraticBezierCurve3,
|
||||
Texture,
|
||||
BufferAttribute,
|
||||
BufferGeometry,
|
||||
SphereGeometry,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import * as THREE from 'three';
|
||||
import { getGlowTexture } from './nodes';
|
||||
|
||||
export interface PulseEffect {
|
||||
nodeId: string;
|
||||
|
|
@ -49,6 +50,33 @@ interface ConnectionFlash {
|
|||
intensity: number;
|
||||
}
|
||||
|
||||
// v2.3 Memory Birth Ritual. The orb gestates at a camera-relative "cosmic
|
||||
// center" point for `gestationFrames`, then flies along a dynamic quadratic
|
||||
// Bezier curve to the live position of its target node for `flightFrames`,
|
||||
// then calls `onArrive` and disposes itself. The target position is
|
||||
// resolved via `getTargetPos` on every frame so the force simulation can
|
||||
// move the node during the flight and the orb stays glued to it.
|
||||
interface BirthOrb {
|
||||
sprite: THREE.Sprite;
|
||||
core: THREE.Sprite;
|
||||
startPos: THREE.Vector3;
|
||||
getTargetPos: () => THREE.Vector3 | undefined;
|
||||
color: THREE.Color;
|
||||
age: number;
|
||||
gestationFrames: number;
|
||||
flightFrames: number;
|
||||
arriveFired: boolean;
|
||||
onArrive: () => void;
|
||||
/** Last known target position. If the target disappears mid-flight we keep
|
||||
* using this snapshot so the orb still lands somewhere sensible. */
|
||||
lastTargetPos: THREE.Vector3;
|
||||
/** v2.3: Sanhedrin-Shatter state. Set true when getTargetPos returns
|
||||
* undefined after gestation — the Stop hook deleted the target node
|
||||
* mid-ritual, so we short-circuit the arrival cascade and implode
|
||||
* the orb in place as the "cognitive immune system" visual. */
|
||||
aborted: boolean;
|
||||
}
|
||||
|
||||
export class EffectManager {
|
||||
pulseEffects: PulseEffect[] = [];
|
||||
private spawnBursts: SpawnBurst[] = [];
|
||||
|
|
@ -57,6 +85,7 @@ export class EffectManager {
|
|||
private implosions: ImplosionEffect[] = [];
|
||||
private shockwaves: Shockwave[] = [];
|
||||
private connectionFlashes: ConnectionFlash[] = [];
|
||||
private birthOrbs: BirthOrb[] = [];
|
||||
private scene: THREE.Scene;
|
||||
|
||||
constructor(scene: THREE.Scene) {
|
||||
|
|
@ -231,6 +260,89 @@ export class EffectManager {
|
|||
this.connectionFlashes.push({ line, intensity: 1.0 });
|
||||
}
|
||||
|
||||
/**
|
||||
* v2.3 Memory Birth Ritual. Spawn a glowing orb at a point in front of the
|
||||
* camera ("cosmic center"), gestate for ~800ms, then arc along a quadratic
|
||||
* Bezier curve to the live position of the target node, which is resolved
|
||||
* on every frame via `getTargetPos`. On arrival, `onArrive` fires — caller
|
||||
* is responsible for adding the real node to the graph and triggering
|
||||
* arrival-time bursts.
|
||||
*
|
||||
* The target getter can return undefined if the node has been removed
|
||||
* mid-flight; the orb then flies to the last known target position so the
|
||||
* burst still fires somewhere coherent rather than snapping to origin.
|
||||
*/
|
||||
createBirthOrb(
|
||||
camera: THREE.Camera,
|
||||
color: THREE.Color,
|
||||
getTargetPos: () => THREE.Vector3 | undefined,
|
||||
onArrive: () => void,
|
||||
opts: { gestationFrames?: number; flightFrames?: number; distanceFromCamera?: number } = {}
|
||||
) {
|
||||
const gestationFrames = opts.gestationFrames ?? 48; // ~800ms
|
||||
const flightFrames = opts.flightFrames ?? 90; // ~1500ms
|
||||
const distanceFromCamera = opts.distanceFromCamera ?? 40;
|
||||
|
||||
// Place the orb slightly in front of the camera, in view-space,
|
||||
// projected back into world coordinates. This way the orb always
|
||||
// appears "right in front of the user's face" regardless of where
|
||||
// the camera has been orbited to.
|
||||
const startPos = new THREE.Vector3(0, 0, -distanceFromCamera)
|
||||
.applyQuaternion(camera.quaternion)
|
||||
.add(camera.position);
|
||||
|
||||
// Outer glow halo
|
||||
const haloMat = new THREE.SpriteMaterial({
|
||||
map: getGlowTexture(),
|
||||
color: color.clone(),
|
||||
transparent: true,
|
||||
opacity: 0.0,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
depthTest: false, // always visible, even through other nodes
|
||||
});
|
||||
const sprite = new THREE.Sprite(haloMat);
|
||||
sprite.position.copy(startPos);
|
||||
sprite.scale.set(0.5, 0.5, 1);
|
||||
sprite.renderOrder = 999;
|
||||
|
||||
// Inner bright core — stays hot white during gestation, tints at launch
|
||||
const coreMat = new THREE.SpriteMaterial({
|
||||
map: getGlowTexture(),
|
||||
color: new THREE.Color(0xffffff),
|
||||
transparent: true,
|
||||
opacity: 0.0,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
depthTest: false,
|
||||
});
|
||||
const core = new THREE.Sprite(coreMat);
|
||||
core.position.copy(startPos);
|
||||
core.scale.set(0.2, 0.2, 1);
|
||||
core.renderOrder = 1000;
|
||||
|
||||
this.scene.add(sprite);
|
||||
this.scene.add(core);
|
||||
|
||||
// Snapshot the current target so we have a fallback.
|
||||
const initialTarget = getTargetPos()?.clone() ?? startPos.clone();
|
||||
|
||||
this.birthOrbs.push({
|
||||
sprite,
|
||||
core,
|
||||
startPos,
|
||||
getTargetPos,
|
||||
color: color.clone(),
|
||||
age: 0,
|
||||
gestationFrames,
|
||||
flightFrames,
|
||||
arriveFired: false,
|
||||
onArrive,
|
||||
lastTargetPos: initialTarget,
|
||||
aborted: false,
|
||||
});
|
||||
}
|
||||
|
||||
update(
|
||||
nodeMeshMap: Map<string, THREE.Mesh>,
|
||||
camera: THREE.Camera,
|
||||
|
|
@ -431,6 +543,122 @@ export class EffectManager {
|
|||
}
|
||||
(flash.line.material as THREE.LineBasicMaterial).opacity = flash.intensity;
|
||||
}
|
||||
|
||||
// v2.3 Birth orbs — gestate at cosmic center, then arc to live node
|
||||
// position along a quadratic Bezier curve. Target position is
|
||||
// re-resolved every frame so the force simulation can move the
|
||||
// destination during flight without the orb losing its mark.
|
||||
for (let i = this.birthOrbs.length - 1; i >= 0; i--) {
|
||||
const orb = this.birthOrbs[i];
|
||||
orb.age++;
|
||||
const totalFrames = orb.gestationFrames + orb.flightFrames;
|
||||
|
||||
const haloMat = orb.sprite.material as THREE.SpriteMaterial;
|
||||
const coreMat = orb.core.material as THREE.SpriteMaterial;
|
||||
|
||||
// Refresh the live target snapshot. If the target getter returns
|
||||
// undefined DURING flight (not just at spawn), the node was
|
||||
// aborted mid-ritual — typically a Sanhedrin veto deleting a
|
||||
// hallucination node while the orb was still in transit. Trigger
|
||||
// the anti-birth: turn red, implode in place, stop tracking.
|
||||
const live = orb.getTargetPos();
|
||||
if (live) {
|
||||
orb.lastTargetPos.copy(live);
|
||||
} else if (orb.age > orb.gestationFrames && !orb.aborted) {
|
||||
orb.aborted = true;
|
||||
// Fire an implosion where the orb currently is, then splice
|
||||
// out on the next tick by jumping age to the end of life.
|
||||
const pos = orb.sprite.position;
|
||||
haloMat.color.setRGB(1.0, 0.15, 0.2); // blood red
|
||||
coreMat.color.setRGB(1.0, 0.6, 0.6);
|
||||
this.createImplosion(pos, new THREE.Color(0xff2533));
|
||||
orb.arriveFired = true;
|
||||
orb.age = totalFrames + 1;
|
||||
}
|
||||
|
||||
if (orb.age <= orb.gestationFrames) {
|
||||
// Gestation phase — pulse brighter + grow from a tiny spark
|
||||
// into a full orb. Sits still at the cosmic center.
|
||||
const t = orb.age / orb.gestationFrames;
|
||||
const ease = 1 - Math.pow(1 - t, 3); // easeOutCubic
|
||||
const pulse = 0.85 + Math.sin(orb.age * 0.35) * 0.15;
|
||||
const haloScale = 0.5 + ease * 4.5 * pulse;
|
||||
const coreScale = 0.2 + ease * 1.8 * pulse;
|
||||
orb.sprite.scale.set(haloScale, haloScale, 1);
|
||||
orb.core.scale.set(coreScale, coreScale, 1);
|
||||
haloMat.opacity = ease * 0.95;
|
||||
coreMat.opacity = ease;
|
||||
// Subtle warm-up — core white, halo tints toward the event
|
||||
// color as gestation completes.
|
||||
haloMat.color.copy(orb.color).multiplyScalar(0.7 + ease * 0.3);
|
||||
orb.sprite.position.copy(orb.startPos);
|
||||
orb.core.position.copy(orb.startPos);
|
||||
} else if (orb.age <= totalFrames) {
|
||||
// Flight phase — inline quadratic Bezier eval. Zero-alloc:
|
||||
// no new Vector3 or QuadraticBezierCurve3 per frame, which
|
||||
// would flood the GC when several orbs are in flight.
|
||||
const t = (orb.age - orb.gestationFrames) / orb.flightFrames;
|
||||
const ease = t < 0.5
|
||||
? 2 * t * t
|
||||
: 1 - Math.pow(-2 * t + 2, 2) / 2; // easeInOutQuad
|
||||
|
||||
const s = orb.startPos;
|
||||
const tgt = orb.lastTargetPos;
|
||||
const dx = tgt.x - s.x;
|
||||
const dy = tgt.y - s.y;
|
||||
const dz = tgt.z - s.z;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
|
||||
const cx = (s.x + tgt.x) * 0.5;
|
||||
const cy = (s.y + tgt.y) * 0.5 + 30 + dist * 0.15;
|
||||
const cz = (s.z + tgt.z) * 0.5;
|
||||
|
||||
const oneMinusE = 1 - ease;
|
||||
const w0 = oneMinusE * oneMinusE;
|
||||
const w1 = 2 * oneMinusE * ease;
|
||||
const w2 = ease * ease;
|
||||
const px = w0 * s.x + w1 * cx + w2 * tgt.x;
|
||||
const py = w0 * s.y + w1 * cy + w2 * tgt.y;
|
||||
const pz = w0 * s.z + w1 * cz + w2 * tgt.z;
|
||||
|
||||
orb.sprite.position.set(px, py, pz);
|
||||
orb.core.position.set(px, py, pz);
|
||||
|
||||
// Trail effect — shrink + brighten as it approaches target
|
||||
const shrink = 1 - ease * 0.35;
|
||||
orb.sprite.scale.setScalar(5 * shrink);
|
||||
orb.core.scale.setScalar(2 * shrink);
|
||||
haloMat.opacity = 0.95;
|
||||
coreMat.opacity = 1.0;
|
||||
// Shift halo fully to the event color during flight
|
||||
haloMat.color.copy(orb.color);
|
||||
} else if (!orb.arriveFired) {
|
||||
// Docking — fire the arrival callback once. Let the caller
|
||||
// trigger burst/ripple effects at the exact target point.
|
||||
orb.arriveFired = true;
|
||||
try {
|
||||
orb.onArrive();
|
||||
} catch (e) {
|
||||
// Effects must never take down the render loop.
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[birth-orb] onArrive threw', e);
|
||||
}
|
||||
// Fade the orb out over a few more frames instead of popping.
|
||||
} else {
|
||||
// Post-arrival fade (8 frames ≈ 130ms)
|
||||
const fadeAge = orb.age - totalFrames;
|
||||
const fade = Math.max(0, 1 - fadeAge / 8);
|
||||
haloMat.opacity = 0.95 * fade;
|
||||
coreMat.opacity = 1.0 * fade;
|
||||
orb.sprite.scale.setScalar(5 * (1 + (1 - fade) * 2));
|
||||
if (fade <= 0) {
|
||||
this.scene.remove(orb.sprite);
|
||||
this.scene.remove(orb.core);
|
||||
haloMat.dispose();
|
||||
coreMat.dispose();
|
||||
this.birthOrbs.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
|
|
@ -464,6 +692,12 @@ export class EffectManager {
|
|||
flash.line.geometry.dispose();
|
||||
(flash.line.material as THREE.Material).dispose();
|
||||
}
|
||||
for (const orb of this.birthOrbs) {
|
||||
this.scene.remove(orb.sprite);
|
||||
this.scene.remove(orb.core);
|
||||
(orb.sprite.material as THREE.Material).dispose();
|
||||
(orb.core.material as THREE.Material).dispose();
|
||||
}
|
||||
this.pulseEffects = [];
|
||||
this.spawnBursts = [];
|
||||
this.rainbowBursts = [];
|
||||
|
|
@ -471,5 +705,6 @@ export class EffectManager {
|
|||
this.implosions = [];
|
||||
this.shockwaves = [];
|
||||
this.connectionFlashes = [];
|
||||
this.birthOrbs = [];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -125,25 +125,59 @@ export function mapEventToEffects(
|
|||
// Find spawn position near related nodes
|
||||
const spawnPos = findSpawnPosition(newNode, allNodes, nodePositions);
|
||||
|
||||
// Add to all managers
|
||||
const pos = nodeManager.addNode(newNode, spawnPos);
|
||||
// Reserve the physics slot but hide the node until the orb docks.
|
||||
// `isBirthRitual:true` skips the 30-frame materialization push, so
|
||||
// the mesh/glow/label stay invisible; `igniteNode` below flips
|
||||
// visibility and kicks off the elastic scale-up AT the exact
|
||||
// millisecond the orb lands — not 100 frames before.
|
||||
const pos = nodeManager.addNode(newNode, spawnPos, { isBirthRitual: true });
|
||||
forceSim.addNode(data.id, pos);
|
||||
|
||||
// FIFO eviction
|
||||
liveSpawnedNodes.push(data.id);
|
||||
evictOldestLiveNode(ctx, allNodes);
|
||||
|
||||
// Spectacular effects: rainbow burst + double shockwave + ripple wave
|
||||
// v2.3 Memory Birth Ritual — cosmic-center orb, Bezier flight,
|
||||
// arrival burst cascade. The burst/ripple/shockwave cascade
|
||||
// fires on arrival at the docking target, not at spawn, so the
|
||||
// eye tracks the orb in and the visuals peak on contact.
|
||||
const color = new THREE.Color(NODE_TYPE_COLORS[newNode.type] || '#00ffd1');
|
||||
effects.createRainbowBurst(spawnPos, color);
|
||||
effects.createShockwave(spawnPos, color, camera);
|
||||
// Second shockwave, hue-shifted, delayed via smaller initial scale
|
||||
const hueShifted = color.clone();
|
||||
hueShifted.offsetHSL(0.15, 0, 0);
|
||||
setTimeout(() => {
|
||||
effects.createShockwave(spawnPos, hueShifted, camera);
|
||||
}, 166); // ~10 frames at 60fps
|
||||
effects.createRippleWave(spawnPos);
|
||||
|
||||
effects.createBirthOrb(
|
||||
camera,
|
||||
color,
|
||||
// Re-resolve the live target position every frame — the node
|
||||
// is being pushed around by the force sim during flight.
|
||||
// Returning undefined here signals "node was aborted" and
|
||||
// triggers the Sanhedrin-Shatter anti-birth in effects.ts.
|
||||
() => nodeManager.positions.get(newNode.id),
|
||||
() => {
|
||||
// Dock. Ignite the node (flips visibility + starts
|
||||
// materialization) and fire the arrival cascade at the
|
||||
// node's CURRENT position — the force sim may have moved
|
||||
// the target during the ritual, so we re-read positions.
|
||||
nodeManager.igniteNode(newNode.id);
|
||||
const arrivePos = nodeManager.positions.get(newNode.id) ?? spawnPos;
|
||||
|
||||
// Newton's Cradle — kinetic transfer into the graph.
|
||||
// Bump the mesh scale on impact so the easeOutElastic
|
||||
// materialization + force-sim springs physically recoil
|
||||
// instead of the orb docking silently.
|
||||
const mesh = nodeManager.meshMap.get(newNode.id);
|
||||
if (mesh) mesh.scale.multiplyScalar(1.8);
|
||||
|
||||
effects.createRainbowBurst(arrivePos, color);
|
||||
effects.createShockwave(arrivePos, color, camera);
|
||||
// Fire BOTH shockwaves immediately (different scales /
|
||||
// colors for layered crash feel). The previous 166ms
|
||||
// setTimeout could outlive the scene on route change
|
||||
// and throw an unhandled rejection.
|
||||
effects.createShockwave(arrivePos, hueShifted, camera);
|
||||
effects.createRippleWave(arrivePos);
|
||||
}
|
||||
);
|
||||
|
||||
onMutation({ type: 'nodeAdded', node: newNode });
|
||||
break;
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ export function getNodeColor(node: GraphNode, mode: ColorMode): string {
|
|||
// hard-edged "glowing cubes" artefact reported in issue #31. Using a
|
||||
// soft radial gradient gives a real round halo and lets bloom do its job.
|
||||
let sharedGlowTexture: THREE.Texture | null = null;
|
||||
function getGlowTexture(): THREE.Texture {
|
||||
export function getGlowTexture(): THREE.Texture {
|
||||
if (sharedGlowTexture) return sharedGlowTexture;
|
||||
const size = 128;
|
||||
const canvas = document.createElement('canvas');
|
||||
|
|
@ -271,7 +271,11 @@ export class NodeManager {
|
|||
return { mesh, glow: sprite, label: labelSprite, size };
|
||||
}
|
||||
|
||||
addNode(node: GraphNode, initialPosition?: THREE.Vector3): THREE.Vector3 {
|
||||
addNode(
|
||||
node: GraphNode,
|
||||
initialPosition?: THREE.Vector3,
|
||||
options: { isBirthRitual?: boolean } = {}
|
||||
): THREE.Vector3 {
|
||||
const pos =
|
||||
initialPosition?.clone() ??
|
||||
new THREE.Vector3(
|
||||
|
|
@ -289,17 +293,62 @@ export class NodeManager {
|
|||
(glow.material as THREE.SpriteMaterial).opacity = 0;
|
||||
(label.material as THREE.SpriteMaterial).opacity = 0;
|
||||
|
||||
if (options.isBirthRitual) {
|
||||
// v2.3 Birth Ritual: reserve the physics slot but don't show
|
||||
// anything until the orb docks. Hiding via .visible keeps the
|
||||
// force simulation + positions map fully active, so getTargetPos()
|
||||
// can still resolve the live destination for the orb. `igniteNode`
|
||||
// below flips visibility and kicks off the materialization anim.
|
||||
mesh.visible = false;
|
||||
glow.visible = false;
|
||||
label.visible = false;
|
||||
mesh.userData.birthRitualPending = {
|
||||
totalFrames: 30,
|
||||
targetScale: 0.5 + node.retention * 2,
|
||||
};
|
||||
} else {
|
||||
this.materializingNodes.push({
|
||||
id: node.id,
|
||||
frame: 0,
|
||||
totalFrames: 30,
|
||||
mesh,
|
||||
glow,
|
||||
label,
|
||||
targetScale: 0.5 + node.retention * 2,
|
||||
});
|
||||
}
|
||||
|
||||
return pos;
|
||||
}
|
||||
|
||||
/**
|
||||
* v2.3 Birth Ritual docking. Flip visibility and hand the node over to
|
||||
* the materialization queue so it springs up via easeOutElastic at the
|
||||
* exact moment the orb hits. No-op if the node wasn't created with
|
||||
* `isBirthRitual:true` or was already ignited.
|
||||
*/
|
||||
igniteNode(id: string) {
|
||||
const mesh = this.meshMap.get(id);
|
||||
const glow = this.glowMap.get(id);
|
||||
const label = this.labelSprites.get(id);
|
||||
if (!mesh || !glow || !label) return;
|
||||
const pending = mesh.userData.birthRitualPending as
|
||||
| { totalFrames: number; targetScale: number }
|
||||
| undefined;
|
||||
if (!pending) return;
|
||||
mesh.visible = true;
|
||||
glow.visible = true;
|
||||
label.visible = true;
|
||||
delete mesh.userData.birthRitualPending;
|
||||
this.materializingNodes.push({
|
||||
id: node.id,
|
||||
id,
|
||||
frame: 0,
|
||||
totalFrames: 30,
|
||||
totalFrames: pending.totalFrames,
|
||||
mesh,
|
||||
glow,
|
||||
label,
|
||||
targetScale: 0.5 + node.retention * 2,
|
||||
targetScale: pending.targetScale,
|
||||
});
|
||||
|
||||
return pos;
|
||||
}
|
||||
|
||||
removeNode(id: string) {
|
||||
|
|
@ -446,7 +495,12 @@ export class NodeManager {
|
|||
});
|
||||
}
|
||||
|
||||
animate(time: number, nodes: GraphNode[], camera: THREE.PerspectiveCamera) {
|
||||
animate(
|
||||
time: number,
|
||||
nodes: GraphNode[],
|
||||
camera: THREE.PerspectiveCamera,
|
||||
brightness: number = 1.0
|
||||
) {
|
||||
// Materialization animations — elastic scale-up from 0
|
||||
for (let i = this.materializingNodes.length - 1; i >= 0; i--) {
|
||||
const mn = this.materializingNodes[i];
|
||||
|
|
@ -552,16 +606,38 @@ export class NodeManager {
|
|||
1 + Math.sin(time * 1.5 + nodes.indexOf(node) * 0.5) * 0.15 * node.retention;
|
||||
mesh.scale.setScalar(breathe);
|
||||
|
||||
// Distance compensation: FogExp2 attenuates exponentially with camera
|
||||
// distance, so nodes past ~80 units go nearly black unless we push
|
||||
// emissive harder. Boost runs 1.0x at <60 units → ~2.4x at 200 units.
|
||||
// Combined with the user brightness multiplier this gives a visible
|
||||
// floor at every zoom level without blowing out close-up highlights.
|
||||
const pos = this.positions.get(id);
|
||||
const dist = pos ? camera.position.distanceTo(pos) : 0;
|
||||
const distanceBoost = 1 + Math.min(1.4, Math.max(0, (dist - 60) / 100));
|
||||
|
||||
const mat = mesh.material as THREE.MeshStandardMaterial;
|
||||
if (id === this.hoveredNode) {
|
||||
mat.emissiveIntensity = 1.0;
|
||||
mat.emissiveIntensity = 1.0 * brightness;
|
||||
} else if (id === this.selectedNode) {
|
||||
mat.emissiveIntensity = 0.8;
|
||||
mat.emissiveIntensity = 0.8 * brightness;
|
||||
} else {
|
||||
const baseIntensity = 0.3 + node.retention * 0.5;
|
||||
const breatheIntensity =
|
||||
baseIntensity + Math.sin(time * (0.8 + node.retention * 0.7)) * 0.1 * node.retention;
|
||||
mat.emissiveIntensity = breatheIntensity;
|
||||
mat.emissiveIntensity = breatheIntensity * brightness * distanceBoost;
|
||||
}
|
||||
|
||||
// Opacity also gets the distance boost (capped at 1.0) so the node
|
||||
// body stays visible against the dark void at far zoom.
|
||||
const baseOpacity = 0.3 + node.retention * 0.7;
|
||||
mat.opacity = Math.min(1.0, baseOpacity * brightness * distanceBoost);
|
||||
|
||||
// Mirror the boost onto the glow sprite so the halo tracks the core.
|
||||
const glow = this.glowMap.get(id);
|
||||
if (glow) {
|
||||
const glowMat = glow.material as THREE.SpriteMaterial;
|
||||
const baseGlow = 0.3 + node.retention * 0.35;
|
||||
glowMat.opacity = Math.min(0.95, baseGlow * brightness * distanceBoost);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -90,8 +90,15 @@ export function createScene(container: HTMLDivElement): SceneContext {
|
|||
controls.dampingFactor = 0.05;
|
||||
controls.rotateSpeed = 0.5;
|
||||
controls.zoomSpeed = 0.8;
|
||||
controls.minDistance = 10;
|
||||
controls.maxDistance = 500;
|
||||
// Distance clamps — the camera starts at ~86 units from origin
|
||||
// (position.set(0, 30, 80)). The graph's force-directed layout seats
|
||||
// most nodes within a ~120-unit radius. 500 was dramatically out of
|
||||
// scale — the user could zoom out until every node was one pixel on
|
||||
// a black starfield (issue reported 2026-04-23). 180 keeps the full
|
||||
// graph in frame with nodes still readable; 12 prevents zooming inside
|
||||
// a node and losing orientation.
|
||||
controls.minDistance = 12;
|
||||
controls.maxDistance = 180;
|
||||
controls.autoRotate = true;
|
||||
controls.autoRotateSpeed = 0.3;
|
||||
|
||||
|
|
|
|||
496
apps/dashboard/src/lib/stores/__tests__/theme.test.ts
Normal file
496
apps/dashboard/src/lib/stores/__tests__/theme.test.ts
Normal file
|
|
@ -0,0 +1,496 @@
|
|||
/**
|
||||
* Unit tests for the theme store.
|
||||
*
|
||||
* Scope: pure-store behavior — setter validation, cycle order, derived
|
||||
* resolution, localStorage persistence + fallback, matchMedia listener
|
||||
* wiring, idempotent style injection, SSR safety.
|
||||
*
|
||||
* Environment notes:
|
||||
* - Vitest runs in Node (no jsdom). We fabricate the window / document /
|
||||
* localStorage / matchMedia globals the store touches, then mock
|
||||
* `$app/environment` so `browser` flips between true and false per
|
||||
* test group. SSR tests leave `browser` false and verify no globals
|
||||
* are touched.
|
||||
* - The store caches module-level state (mediaQuery, listener,
|
||||
* resolvedUnsub). We `vi.resetModules()` before every test so each
|
||||
* loadTheme() returns a pristine instance.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
// --- Controllable `browser` flag ------------------------------------------
|
||||
// vi.mock is hoisted — we reference a module-level `browserFlag` the tests
|
||||
// mutate between blocks. Casting via globalThis keeps the hoist happy.
|
||||
const browserState = { value: true };
|
||||
vi.mock('$app/environment', () => ({
|
||||
get browser() {
|
||||
return browserState.value;
|
||||
},
|
||||
}));
|
||||
|
||||
// --- Fabricated DOM / storage / matchMedia --------------------------------
|
||||
// Each test's setup wires these onto globalThis so the store's `browser`
|
||||
// branch can read them. They are intentionally minimal — only the methods
|
||||
// theme.ts actually calls are implemented.
|
||||
|
||||
type FakeMediaListener = (e: { matches: boolean }) => void;
|
||||
|
||||
interface FakeMediaQueryList {
|
||||
matches: boolean;
|
||||
addEventListener: (type: 'change', listener: FakeMediaListener) => void;
|
||||
removeEventListener: (type: 'change', listener: FakeMediaListener) => void;
|
||||
// Test-only helpers
|
||||
_emit: (matches: boolean) => void;
|
||||
_listenerCount: () => number;
|
||||
}
|
||||
|
||||
function createFakeMediaQuery(initialMatches: boolean): FakeMediaQueryList {
|
||||
const listeners = new Set<FakeMediaListener>();
|
||||
return {
|
||||
matches: initialMatches,
|
||||
addEventListener: (_type, listener) => {
|
||||
listeners.add(listener);
|
||||
},
|
||||
removeEventListener: (_type, listener) => {
|
||||
listeners.delete(listener);
|
||||
},
|
||||
_emit(matches: boolean) {
|
||||
this.matches = matches;
|
||||
for (const l of listeners) l({ matches });
|
||||
},
|
||||
_listenerCount() {
|
||||
return listeners.size;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface FakeStorageBehavior {
|
||||
throwOnGet?: boolean;
|
||||
throwOnSet?: boolean;
|
||||
corruptRaw?: string | null;
|
||||
}
|
||||
|
||||
function installFakeLocalStorage(behavior: FakeStorageBehavior = {}) {
|
||||
const store = new Map<string, string>();
|
||||
if (behavior.corruptRaw !== undefined && behavior.corruptRaw !== null) {
|
||||
store.set('vestige.theme', behavior.corruptRaw);
|
||||
}
|
||||
const fake = {
|
||||
getItem: (key: string) => {
|
||||
if (behavior.throwOnGet) throw new Error('SecurityError: storage disabled');
|
||||
return store.has(key) ? store.get(key)! : null;
|
||||
},
|
||||
setItem: (key: string, value: string) => {
|
||||
if (behavior.throwOnSet) throw new Error('QuotaExceededError');
|
||||
store.set(key, value);
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
store.delete(key);
|
||||
},
|
||||
clear: () => store.clear(),
|
||||
key: () => null,
|
||||
length: 0,
|
||||
_store: store, // test-only peek
|
||||
};
|
||||
vi.stubGlobal('localStorage', fake);
|
||||
return fake;
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a fake `document` with only the APIs theme.ts calls:
|
||||
* - getElementById (style-dedup check)
|
||||
* - createElement('style')
|
||||
* - head.appendChild
|
||||
* - documentElement.dataset
|
||||
* Returns handles so tests can inspect the head children and data-theme.
|
||||
*/
|
||||
function installFakeDocument() {
|
||||
const headChildren: Array<{ id: string; textContent: string; tagName: string }> = [];
|
||||
const docEl = {
|
||||
dataset: {} as Record<string, string>,
|
||||
};
|
||||
const fakeDocument = {
|
||||
getElementById: (id: string) =>
|
||||
headChildren.find((el) => el.id === id) ?? null,
|
||||
createElement: (tag: string) => ({
|
||||
id: '',
|
||||
textContent: '',
|
||||
tagName: tag.toUpperCase(),
|
||||
}),
|
||||
head: {
|
||||
appendChild: (el: { id: string; textContent: string; tagName: string }) => {
|
||||
headChildren.push(el);
|
||||
return el;
|
||||
},
|
||||
},
|
||||
documentElement: docEl,
|
||||
};
|
||||
vi.stubGlobal('document', fakeDocument);
|
||||
return { fakeDocument, headChildren, docEl };
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a fake `window` with just `matchMedia`. We keep the returned
|
||||
* MQL handle so tests can emit change events.
|
||||
*/
|
||||
function installFakeWindow(initialPrefersDark: boolean) {
|
||||
const mql = createFakeMediaQuery(initialPrefersDark);
|
||||
const fakeWindow = {
|
||||
matchMedia: vi.fn(() => mql),
|
||||
};
|
||||
vi.stubGlobal('window', fakeWindow);
|
||||
return { fakeWindow, mql };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fresh module import. The theme store caches matchMedia/listener handles
|
||||
* at module level, so every test that exercises initTheme wants a clean
|
||||
* copy. Returns the full export surface.
|
||||
*/
|
||||
async function loadTheme() {
|
||||
vi.resetModules();
|
||||
return await import('../theme');
|
||||
}
|
||||
|
||||
// Baseline: every test starts with browser=true, fake window/doc/storage
|
||||
// installed, and fresh module state. SSR-specific tests override these.
|
||||
beforeEach(() => {
|
||||
browserState.value = true;
|
||||
installFakeDocument();
|
||||
installFakeWindow(true); // system prefers dark by default
|
||||
installFakeLocalStorage();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Export surface
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('theme store — exports', () => {
|
||||
it('exports theme writable, resolvedTheme derived, setTheme, cycleTheme, initTheme', async () => {
|
||||
const mod = await loadTheme();
|
||||
expect(mod.theme).toBeDefined();
|
||||
expect(typeof mod.theme.subscribe).toBe('function');
|
||||
expect(typeof mod.theme.set).toBe('function');
|
||||
expect(mod.resolvedTheme).toBeDefined();
|
||||
expect(typeof mod.resolvedTheme.subscribe).toBe('function');
|
||||
// Derived stores do NOT expose .set — this guards against accidental
|
||||
// conversion to a writable during refactors.
|
||||
expect((mod.resolvedTheme as unknown as { set?: unknown }).set).toBeUndefined();
|
||||
expect(typeof mod.setTheme).toBe('function');
|
||||
expect(typeof mod.cycleTheme).toBe('function');
|
||||
expect(typeof mod.initTheme).toBe('function');
|
||||
});
|
||||
|
||||
it('theme defaults to dark before initTheme is called', async () => {
|
||||
const mod = await loadTheme();
|
||||
expect(get(mod.theme)).toBe('dark');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// setTheme — input validation + persistence
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('setTheme', () => {
|
||||
it('accepts dark/light/auto and updates the store', async () => {
|
||||
const { theme, setTheme } = await loadTheme();
|
||||
setTheme('light');
|
||||
expect(get(theme)).toBe('light');
|
||||
setTheme('auto');
|
||||
expect(get(theme)).toBe('auto');
|
||||
setTheme('dark');
|
||||
expect(get(theme)).toBe('dark');
|
||||
});
|
||||
|
||||
it('rejects invalid values — store is unchanged, localStorage untouched', async () => {
|
||||
const { theme, setTheme } = await loadTheme();
|
||||
setTheme('light'); // seed a known value
|
||||
const ls = installFakeLocalStorage();
|
||||
// Reset any prior writes so we only see what happens during the bad call.
|
||||
ls._store.clear();
|
||||
|
||||
// Cast a bad value through the public API.
|
||||
setTheme('midnight' as unknown as 'dark');
|
||||
expect(get(theme)).toBe('light'); // unchanged
|
||||
expect(ls._store.has('vestige.theme')).toBe(false);
|
||||
|
||||
setTheme('' as unknown as 'dark');
|
||||
setTheme(undefined as unknown as 'dark');
|
||||
setTheme(null as unknown as 'dark');
|
||||
expect(get(theme)).toBe('light');
|
||||
});
|
||||
|
||||
it('persists the valid value to localStorage under the vestige.theme key', async () => {
|
||||
const ls = installFakeLocalStorage();
|
||||
const { setTheme } = await loadTheme();
|
||||
setTheme('auto');
|
||||
expect(ls._store.get('vestige.theme')).toBe('auto');
|
||||
});
|
||||
|
||||
it('swallows localStorage write errors (private mode / disabled storage)', async () => {
|
||||
installFakeLocalStorage({ throwOnSet: true });
|
||||
const { theme, setTheme } = await loadTheme();
|
||||
// Must not throw.
|
||||
expect(() => setTheme('light')).not.toThrow();
|
||||
// Store still updated even though persistence failed — UI should
|
||||
// reflect the click; the next session will just start fresh.
|
||||
expect(get(theme)).toBe('light');
|
||||
});
|
||||
|
||||
it('no-ops localStorage write when browser=false (SSR safety)', async () => {
|
||||
browserState.value = false;
|
||||
const ls = installFakeLocalStorage();
|
||||
const { theme, setTheme } = await loadTheme();
|
||||
setTheme('light');
|
||||
// Store update is still safe (pure JS object), but persistence is skipped.
|
||||
expect(get(theme)).toBe('light');
|
||||
expect(ls._store.has('vestige.theme')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// cycleTheme — dark → light → auto → dark
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('cycleTheme', () => {
|
||||
it('cycles dark → light', async () => {
|
||||
const { theme, cycleTheme } = await loadTheme();
|
||||
// Default is 'dark'.
|
||||
expect(get(theme)).toBe('dark');
|
||||
cycleTheme();
|
||||
expect(get(theme)).toBe('light');
|
||||
});
|
||||
|
||||
it('cycles light → auto', async () => {
|
||||
const { theme, setTheme, cycleTheme } = await loadTheme();
|
||||
setTheme('light');
|
||||
cycleTheme();
|
||||
expect(get(theme)).toBe('auto');
|
||||
});
|
||||
|
||||
it('cycles auto → dark (closes the loop)', async () => {
|
||||
const { theme, setTheme, cycleTheme } = await loadTheme();
|
||||
setTheme('auto');
|
||||
cycleTheme();
|
||||
expect(get(theme)).toBe('dark');
|
||||
});
|
||||
|
||||
it('full triple-click returns to the starting value', async () => {
|
||||
const { theme, cycleTheme } = await loadTheme();
|
||||
const start = get(theme);
|
||||
cycleTheme();
|
||||
cycleTheme();
|
||||
cycleTheme();
|
||||
expect(get(theme)).toBe(start);
|
||||
});
|
||||
|
||||
it('persists each step to localStorage', async () => {
|
||||
const ls = installFakeLocalStorage();
|
||||
const { cycleTheme } = await loadTheme();
|
||||
cycleTheme();
|
||||
expect(ls._store.get('vestige.theme')).toBe('light');
|
||||
cycleTheme();
|
||||
expect(ls._store.get('vestige.theme')).toBe('auto');
|
||||
cycleTheme();
|
||||
expect(ls._store.get('vestige.theme')).toBe('dark');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolvedTheme — derived from theme + systemPrefersDark
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('resolvedTheme', () => {
|
||||
it('dark → dark (independent of system preference)', async () => {
|
||||
const { resolvedTheme, setTheme } = await loadTheme();
|
||||
setTheme('dark');
|
||||
expect(get(resolvedTheme)).toBe('dark');
|
||||
});
|
||||
|
||||
it('light → light (independent of system preference)', async () => {
|
||||
const { resolvedTheme, setTheme } = await loadTheme();
|
||||
setTheme('light');
|
||||
expect(get(resolvedTheme)).toBe('light');
|
||||
});
|
||||
|
||||
it('auto + system prefers dark → dark', async () => {
|
||||
const { mql } = installFakeWindow(true);
|
||||
const { resolvedTheme, setTheme, initTheme } = await loadTheme();
|
||||
initTheme(); // primes systemPrefersDark from matchMedia
|
||||
setTheme('auto');
|
||||
expect(mql.matches).toBe(true);
|
||||
expect(get(resolvedTheme)).toBe('dark');
|
||||
});
|
||||
|
||||
it('auto + system prefers light → light', async () => {
|
||||
installFakeWindow(false);
|
||||
const { resolvedTheme, setTheme, initTheme } = await loadTheme();
|
||||
initTheme(); // primes systemPrefersDark=false
|
||||
setTheme('auto');
|
||||
expect(get(resolvedTheme)).toBe('light');
|
||||
});
|
||||
|
||||
it('auto flips live when the matchMedia listener fires (OS changes scheme)', async () => {
|
||||
const { mql } = installFakeWindow(true);
|
||||
const { resolvedTheme, setTheme, initTheme } = await loadTheme();
|
||||
initTheme();
|
||||
setTheme('auto');
|
||||
expect(get(resolvedTheme)).toBe('dark');
|
||||
// OS user toggles to light mode — matchMedia fires 'change' with matches=false.
|
||||
mql._emit(false);
|
||||
expect(get(resolvedTheme)).toBe('light');
|
||||
// And back to dark.
|
||||
mql._emit(true);
|
||||
expect(get(resolvedTheme)).toBe('dark');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// initTheme — idempotence, teardown, localStorage hydration
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('initTheme', () => {
|
||||
it('returns a teardown function', async () => {
|
||||
const { initTheme } = await loadTheme();
|
||||
const teardown = initTheme();
|
||||
expect(typeof teardown).toBe('function');
|
||||
teardown();
|
||||
});
|
||||
|
||||
it('injects exactly one <style id="vestige-theme-light"> into <head>', async () => {
|
||||
const { headChildren } = installFakeDocument();
|
||||
const { initTheme } = await loadTheme();
|
||||
initTheme();
|
||||
const styleEls = headChildren.filter((el) => el.id === 'vestige-theme-light');
|
||||
expect(styleEls.length).toBe(1);
|
||||
expect(styleEls[0].tagName).toBe('STYLE');
|
||||
// Sanity — CSS uses the REAL token names from app.css.
|
||||
expect(styleEls[0].textContent).toContain('--color-void');
|
||||
expect(styleEls[0].textContent).toContain('--color-bright');
|
||||
expect(styleEls[0].textContent).toContain('--color-text');
|
||||
expect(styleEls[0].textContent).toContain("[data-theme='light']");
|
||||
});
|
||||
|
||||
it('is idempotent — double init does NOT duplicate the style element', async () => {
|
||||
const { headChildren } = installFakeDocument();
|
||||
const { initTheme } = await loadTheme();
|
||||
initTheme();
|
||||
initTheme();
|
||||
initTheme();
|
||||
const styleEls = headChildren.filter((el) => el.id === 'vestige-theme-light');
|
||||
expect(styleEls.length).toBe(1);
|
||||
});
|
||||
|
||||
it('double init does not leak matchMedia listeners (tears down the prior one)', async () => {
|
||||
const { mql } = installFakeWindow(true);
|
||||
const { initTheme } = await loadTheme();
|
||||
initTheme();
|
||||
expect(mql._listenerCount()).toBe(1);
|
||||
initTheme();
|
||||
// Still exactly one — the second init removed the first before adding a new one.
|
||||
expect(mql._listenerCount()).toBe(1);
|
||||
initTheme();
|
||||
expect(mql._listenerCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('teardown removes the matchMedia listener', async () => {
|
||||
const { mql } = installFakeWindow(true);
|
||||
const { initTheme } = await loadTheme();
|
||||
const teardown = initTheme();
|
||||
expect(mql._listenerCount()).toBe(1);
|
||||
teardown();
|
||||
expect(mql._listenerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('hydrates theme from localStorage when a valid value is stored', async () => {
|
||||
installFakeLocalStorage({ corruptRaw: 'light' });
|
||||
const { theme, initTheme } = await loadTheme();
|
||||
initTheme();
|
||||
expect(get(theme)).toBe('light');
|
||||
});
|
||||
|
||||
it('falls back to dark when localStorage contains a corrupt/unknown value', async () => {
|
||||
installFakeLocalStorage({ corruptRaw: 'hyperdark' });
|
||||
const { theme, initTheme } = await loadTheme();
|
||||
initTheme();
|
||||
expect(get(theme)).toBe('dark');
|
||||
});
|
||||
|
||||
it('falls back to dark when localStorage.getItem throws (private mode)', async () => {
|
||||
installFakeLocalStorage({ throwOnGet: true });
|
||||
const { theme, initTheme } = await loadTheme();
|
||||
// Must not throw — error swallowed, default preserved.
|
||||
expect(() => initTheme()).not.toThrow();
|
||||
expect(get(theme)).toBe('dark');
|
||||
});
|
||||
|
||||
it('writes documentElement.dataset.theme to the resolved value', async () => {
|
||||
const { docEl } = installFakeDocument();
|
||||
installFakeWindow(true);
|
||||
const { setTheme, initTheme } = await loadTheme();
|
||||
initTheme();
|
||||
setTheme('light');
|
||||
expect(docEl.dataset.theme).toBe('light');
|
||||
setTheme('dark');
|
||||
expect(docEl.dataset.theme).toBe('dark');
|
||||
// auto + system=dark → 'dark'
|
||||
setTheme('auto');
|
||||
expect(docEl.dataset.theme).toBe('dark');
|
||||
});
|
||||
|
||||
it('uses the correct matchMedia query: (prefers-color-scheme: dark)', async () => {
|
||||
const { fakeWindow } = installFakeWindow(true);
|
||||
const { initTheme } = await loadTheme();
|
||||
initTheme();
|
||||
expect(fakeWindow.matchMedia).toHaveBeenCalledWith('(prefers-color-scheme: dark)');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SSR safety — browser=false means every function is a safe no-op
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('SSR safety (browser=false)', () => {
|
||||
beforeEach(() => {
|
||||
browserState.value = false;
|
||||
// Deliberately DO NOT install fake window/document/localStorage.
|
||||
// If the store touches them while browser=false, ReferenceError fires.
|
||||
vi.unstubAllGlobals();
|
||||
// But `setup.ts` (shared graph test setup) installs a minimal global
|
||||
// `document` stub. That's fine — the point is the store must not
|
||||
// call window.matchMedia or localStorage while browser=false.
|
||||
});
|
||||
|
||||
it('initTheme returns a no-op teardown and does not throw', async () => {
|
||||
const { initTheme } = await loadTheme();
|
||||
let teardown: () => void = () => {};
|
||||
expect(() => {
|
||||
teardown = initTheme();
|
||||
}).not.toThrow();
|
||||
expect(typeof teardown).toBe('function');
|
||||
expect(() => teardown()).not.toThrow();
|
||||
});
|
||||
|
||||
it('setTheme updates the store but skips localStorage', async () => {
|
||||
const { theme, setTheme } = await loadTheme();
|
||||
expect(() => setTheme('light')).not.toThrow();
|
||||
expect(get(theme)).toBe('light');
|
||||
});
|
||||
|
||||
it('cycleTheme cycles without touching browser globals', async () => {
|
||||
const { theme, cycleTheme } = await loadTheme();
|
||||
expect(() => cycleTheme()).not.toThrow();
|
||||
expect(get(theme)).toBe('light');
|
||||
});
|
||||
|
||||
it('resolvedTheme returns the concrete value for dark/light, defaults to dark for auto', async () => {
|
||||
const { resolvedTheme, setTheme } = await loadTheme();
|
||||
setTheme('dark');
|
||||
expect(get(resolvedTheme)).toBe('dark');
|
||||
setTheme('light');
|
||||
expect(get(resolvedTheme)).toBe('light');
|
||||
// In SSR we never primed matchMedia, so systemPrefersDark is its
|
||||
// default (true) → auto resolves to dark. This keeps server-rendered
|
||||
// HTML matching the dark-first design.
|
||||
setTheme('auto');
|
||||
expect(get(resolvedTheme)).toBe('dark');
|
||||
});
|
||||
});
|
||||
661
apps/dashboard/src/lib/stores/__tests__/toast.test.ts
Normal file
661
apps/dashboard/src/lib/stores/__tests__/toast.test.ts
Normal file
|
|
@ -0,0 +1,661 @@
|
|||
// Unit tests for the Pulse toast store (v2.2).
|
||||
//
|
||||
// The store subscribes to `eventFeed` from `$stores/websocket` at IMPORT
|
||||
// TIME, so every test re-imports the module via `vi.resetModules()` +
|
||||
// dynamic import to get a fresh `lastSeen` / `nextId` / `lastConnectionAt`
|
||||
// / dwell-timer registry. Without this, the module-level state leaks
|
||||
// between tests (especially the 1500ms ConnectionDiscovered throttle).
|
||||
//
|
||||
// The eventFeed is mocked as a plain writable<VestigeEvent[]> — we push
|
||||
// arrays directly, mirroring the way the real websocket store prepends
|
||||
// new events at index 0.
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { writable, get, type Writable } from 'svelte/store';
|
||||
import type { VestigeEvent } from '$types';
|
||||
|
||||
// The mock `eventFeed` is hoisted so vi.mock can reference it.
|
||||
const mockEventFeed: Writable<VestigeEvent[]> = writable<VestigeEvent[]>([]);
|
||||
|
||||
vi.mock('$stores/websocket', () => ({
|
||||
eventFeed: mockEventFeed,
|
||||
}));
|
||||
|
||||
// Helper — make a fresh VestigeEvent with a unique object identity.
|
||||
// The store uses reference equality (e === lastSeen) to detect freshness,
|
||||
// so every emission must be a distinct object.
|
||||
function makeEvent<T extends VestigeEvent['type']>(
|
||||
type: T,
|
||||
data: Record<string, unknown> = {},
|
||||
): VestigeEvent {
|
||||
return { type, data };
|
||||
}
|
||||
|
||||
// Prepend events onto the feed — mirrors the real websocket store, which
|
||||
// does `[parsed, ...events].slice(0, 200)`. Pass a single event or an
|
||||
// array (oldest-last, so `push([newest, older, oldest])` is the shape
|
||||
// the real subscriber sees).
|
||||
function emit(events: VestigeEvent | VestigeEvent[]) {
|
||||
const arr = Array.isArray(events) ? events : [events];
|
||||
mockEventFeed.update((prev) => [...arr, ...prev]);
|
||||
}
|
||||
|
||||
// Reset the feed between tests. Combined with vi.resetModules() this
|
||||
// guarantees each test starts with a virgin toast store.
|
||||
function resetFeed() {
|
||||
mockEventFeed.set([]);
|
||||
}
|
||||
|
||||
// Dynamically import the toast store after resetModules so we get a
|
||||
// fresh subscription + fresh module-level state every test.
|
||||
async function loadToastStore() {
|
||||
const mod = await import('../toast');
|
||||
return mod;
|
||||
}
|
||||
|
||||
describe('toast store', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
resetFeed();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Identity-based batch walk (silent-lobotomy fix)
|
||||
// ---------------------------------------------------------------
|
||||
describe('identity-based batch walk', () => {
|
||||
it('processes ALL events when multiple land in one tick, not just the newest', async () => {
|
||||
const { toasts } = await loadToastStore();
|
||||
|
||||
const e1 = makeEvent('DreamCompleted', {
|
||||
memories_replayed: 3,
|
||||
connections_found: 1,
|
||||
insights_generated: 0,
|
||||
duration_ms: 500,
|
||||
});
|
||||
const e2 = makeEvent('ConnectionDiscovered', {
|
||||
connection_type: 'semantic',
|
||||
weight: 0.8,
|
||||
});
|
||||
const e3 = makeEvent('MemoryPromoted', { new_retention: 0.9 });
|
||||
|
||||
// All three land in the same tick — emit as a single array
|
||||
// (oldest-last, matching the real store prepend order).
|
||||
emit([e3, e2, e1]);
|
||||
|
||||
const list = get(toasts);
|
||||
expect(list.length).toBe(3);
|
||||
// Queue is newest-first (store prepends): [e3, e2, e1]
|
||||
expect(list[0].type).toBe('MemoryPromoted');
|
||||
expect(list[1].type).toBe('ConnectionDiscovered');
|
||||
expect(list[2].type).toBe('DreamCompleted');
|
||||
});
|
||||
|
||||
it('processes events in OLDEST-first narrative order (DreamCompleted before ConnectionDiscovered)', async () => {
|
||||
const { toasts } = await loadToastStore();
|
||||
|
||||
const dream = makeEvent('DreamCompleted', {
|
||||
memories_replayed: 10,
|
||||
connections_found: 2,
|
||||
insights_generated: 1,
|
||||
duration_ms: 800,
|
||||
});
|
||||
const bridge = makeEvent('ConnectionDiscovered', {
|
||||
connection_type: 'causal',
|
||||
weight: 0.75,
|
||||
});
|
||||
|
||||
// dream is older, bridge is newer → emit [bridge, dream]
|
||||
emit([bridge, dream]);
|
||||
|
||||
const list = get(toasts);
|
||||
// IDs are assigned sequentially as events are processed. Dream
|
||||
// gets processed first (oldest-first walk) → id=1. Bridge → id=2.
|
||||
// Store prepends, so the queue is [bridge(2), dream(1)].
|
||||
expect(list[0].id).toBeGreaterThan(list[1].id);
|
||||
expect(list[1].type).toBe('DreamCompleted');
|
||||
expect(list[0].type).toBe('ConnectionDiscovered');
|
||||
});
|
||||
|
||||
it('does not duplicate toasts when the subscriber re-fires with no new events', async () => {
|
||||
const { toasts } = await loadToastStore();
|
||||
|
||||
const e = makeEvent('MemoryPromoted', { new_retention: 0.85 });
|
||||
emit(e);
|
||||
expect(get(toasts).length).toBe(1);
|
||||
|
||||
// Re-setting the same array (no new events) must NOT produce a
|
||||
// second toast. Also pushing an unrelated no-op update.
|
||||
mockEventFeed.update((prev) => [...prev]);
|
||||
expect(get(toasts).length).toBe(1);
|
||||
});
|
||||
|
||||
it('handles empty feed updates gracefully (no toasts created)', async () => {
|
||||
const { toasts } = await loadToastStore();
|
||||
|
||||
// Force the subscriber to fire with an empty array.
|
||||
mockEventFeed.set([]);
|
||||
expect(get(toasts).length).toBe(0);
|
||||
});
|
||||
|
||||
it('falls back gracefully when lastSeen is evicted from the capped feed', async () => {
|
||||
const { toasts } = await loadToastStore();
|
||||
|
||||
// Emit a first event that becomes lastSeen.
|
||||
const first = makeEvent('MemoryPromoted', { new_retention: 0.8 });
|
||||
emit(first);
|
||||
expect(get(toasts).length).toBe(1);
|
||||
|
||||
// Now emit a burst where the old lastSeen is pushed out. Since
|
||||
// we can never match it by identity, the walk goes to the end
|
||||
// of the array and translates everything.
|
||||
const burst = [
|
||||
makeEvent('MemoryPromoted', { new_retention: 0.81 }),
|
||||
makeEvent('MemoryPromoted', { new_retention: 0.82 }),
|
||||
makeEvent('MemoryPromoted', { new_retention: 0.83 }),
|
||||
];
|
||||
// Replace the feed entirely — the old `first` event is gone.
|
||||
mockEventFeed.set(burst);
|
||||
|
||||
// All three new events get translated. Plus the one we already had.
|
||||
expect(get(toasts).length).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Event translation — one test per meaningful type
|
||||
// ---------------------------------------------------------------
|
||||
describe('event translation', () => {
|
||||
it('DreamCompleted → title + body with replayed/connections/insights/duration', async () => {
|
||||
const { toasts } = await loadToastStore();
|
||||
|
||||
emit(
|
||||
makeEvent('DreamCompleted', {
|
||||
memories_replayed: 127,
|
||||
connections_found: 43,
|
||||
insights_generated: 5,
|
||||
duration_ms: 2400,
|
||||
}),
|
||||
);
|
||||
|
||||
const t = get(toasts)[0];
|
||||
expect(t.title).toBe('Dream consolidated');
|
||||
expect(t.body).toContain('Replayed 127 memories');
|
||||
expect(t.body).toContain('43 new connections');
|
||||
expect(t.body).toContain('5 insights');
|
||||
expect(t.body).toContain('2.4s');
|
||||
});
|
||||
|
||||
it('DreamCompleted → singular grammar when replayed === 1 and found === 1', async () => {
|
||||
const { toasts } = await loadToastStore();
|
||||
|
||||
emit(
|
||||
makeEvent('DreamCompleted', {
|
||||
memories_replayed: 1,
|
||||
connections_found: 1,
|
||||
insights_generated: 1,
|
||||
duration_ms: 300,
|
||||
}),
|
||||
);
|
||||
|
||||
const t = get(toasts)[0];
|
||||
expect(t.body).toContain('Replayed 1 memory');
|
||||
expect(t.body).toContain('1 new connection');
|
||||
expect(t.body).not.toContain('1 new connections');
|
||||
expect(t.body).toContain('1 insight');
|
||||
});
|
||||
|
||||
it('ConsolidationCompleted → title + body with nodes/decay/embedded/duration', async () => {
|
||||
const { toasts } = await loadToastStore();
|
||||
|
||||
emit(
|
||||
makeEvent('ConsolidationCompleted', {
|
||||
nodes_processed: 892,
|
||||
decay_applied: 156,
|
||||
embeddings_generated: 48,
|
||||
duration_ms: 1100,
|
||||
}),
|
||||
);
|
||||
|
||||
const t = get(toasts)[0];
|
||||
expect(t.title).toBe('Consolidation swept');
|
||||
expect(t.body).toContain('892 nodes');
|
||||
expect(t.body).toContain('156 decayed');
|
||||
expect(t.body).toContain('48 embedded');
|
||||
expect(t.body).toContain('1.1s');
|
||||
});
|
||||
|
||||
it('ConnectionDiscovered → title + connection type + weight', async () => {
|
||||
const { toasts } = await loadToastStore();
|
||||
|
||||
emit(
|
||||
makeEvent('ConnectionDiscovered', {
|
||||
connection_type: 'semantic',
|
||||
weight: 0.87,
|
||||
}),
|
||||
);
|
||||
|
||||
const t = get(toasts)[0];
|
||||
expect(t.title).toBe('Bridge discovered');
|
||||
expect(t.body).toContain('semantic');
|
||||
expect(t.body).toContain('0.87');
|
||||
});
|
||||
|
||||
it('MemoryPromoted → body includes retention %', async () => {
|
||||
const { toasts } = await loadToastStore();
|
||||
|
||||
emit(makeEvent('MemoryPromoted', { new_retention: 0.85 }));
|
||||
|
||||
const t = get(toasts)[0];
|
||||
expect(t.title).toBe('Memory promoted');
|
||||
expect(t.body).toBe('retention 85%');
|
||||
});
|
||||
|
||||
it('MemoryDemoted → body includes retention %', async () => {
|
||||
const { toasts } = await loadToastStore();
|
||||
|
||||
emit(makeEvent('MemoryDemoted', { new_retention: 0.42 }));
|
||||
|
||||
const t = get(toasts)[0];
|
||||
expect(t.title).toBe('Memory demoted');
|
||||
expect(t.body).toBe('retention 42%');
|
||||
});
|
||||
|
||||
it('MemorySuppressed (cascade=0) → suppression # only', async () => {
|
||||
const { toasts } = await loadToastStore();
|
||||
|
||||
emit(
|
||||
makeEvent('MemorySuppressed', {
|
||||
suppression_count: 3,
|
||||
estimated_cascade: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
const t = get(toasts)[0];
|
||||
expect(t.title).toBe('Forgetting');
|
||||
expect(t.body).toBe('suppression #3');
|
||||
expect(t.body).not.toContain('Rac1');
|
||||
});
|
||||
|
||||
it('MemorySuppressed (cascade>0) → suppression # + Rac1 cascade mention', async () => {
|
||||
const { toasts } = await loadToastStore();
|
||||
|
||||
emit(
|
||||
makeEvent('MemorySuppressed', {
|
||||
suppression_count: 2,
|
||||
estimated_cascade: 8,
|
||||
}),
|
||||
);
|
||||
|
||||
const t = get(toasts)[0];
|
||||
expect(t.body).toContain('suppression #2');
|
||||
expect(t.body).toContain('Rac1 cascade');
|
||||
expect(t.body).toContain('~8 neighbors');
|
||||
});
|
||||
|
||||
it('MemoryUnsuppressed (remaining>0) → remaining count', async () => {
|
||||
const { toasts } = await loadToastStore();
|
||||
|
||||
emit(makeEvent('MemoryUnsuppressed', { remaining_count: 2 }));
|
||||
|
||||
const t = get(toasts)[0];
|
||||
expect(t.title).toBe('Recovered');
|
||||
expect(t.body).toContain('2 suppressions remain');
|
||||
});
|
||||
|
||||
it('MemoryUnsuppressed (remaining=0) → "fully unsuppressed"', async () => {
|
||||
const { toasts } = await loadToastStore();
|
||||
|
||||
emit(makeEvent('MemoryUnsuppressed', { remaining_count: 0 }));
|
||||
|
||||
const t = get(toasts)[0];
|
||||
expect(t.body).toBe('fully unsuppressed');
|
||||
});
|
||||
|
||||
it('Rac1CascadeSwept → seeds + neighbors affected', async () => {
|
||||
const { toasts } = await loadToastStore();
|
||||
|
||||
emit(
|
||||
makeEvent('Rac1CascadeSwept', {
|
||||
seeds: 3,
|
||||
neighbors_affected: 14,
|
||||
}),
|
||||
);
|
||||
|
||||
const t = get(toasts)[0];
|
||||
expect(t.title).toBe('Rac1 cascade');
|
||||
expect(t.body).toContain('3 seeds');
|
||||
expect(t.body).toContain('14 dendritic spines');
|
||||
expect(t.body).toContain('pruned');
|
||||
});
|
||||
|
||||
it('MemoryDeleted → body is id truncated to first 8 chars', async () => {
|
||||
const { toasts } = await loadToastStore();
|
||||
|
||||
emit(
|
||||
makeEvent('MemoryDeleted', {
|
||||
id: 'deadbeefcafef00d1234567890abcdef',
|
||||
}),
|
||||
);
|
||||
|
||||
const t = get(toasts)[0];
|
||||
expect(t.title).toBe('Memory deleted');
|
||||
expect(t.body).toBe('deadbeef');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['Heartbeat'],
|
||||
['SearchPerformed'],
|
||||
['RetentionDecayed'],
|
||||
['ActivationSpread'],
|
||||
['ImportanceScored'],
|
||||
['MemoryCreated'],
|
||||
['MemoryUpdated'],
|
||||
['DreamStarted'],
|
||||
['DreamProgress'],
|
||||
['ConsolidationStarted'],
|
||||
['Connected'],
|
||||
] as const)('noise event %s produces no toast', async ([type]) => {
|
||||
const { toasts } = await loadToastStore();
|
||||
|
||||
emit(makeEvent(type as VestigeEvent['type'], {}));
|
||||
|
||||
expect(get(toasts).length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// ConnectionDiscovered throttle
|
||||
// ---------------------------------------------------------------
|
||||
describe('ConnectionDiscovered throttle', () => {
|
||||
it('two ConnectionDiscovered within 1500ms → only one toast', async () => {
|
||||
const { toasts } = await loadToastStore();
|
||||
|
||||
emit(
|
||||
makeEvent('ConnectionDiscovered', {
|
||||
connection_type: 'semantic',
|
||||
weight: 0.8,
|
||||
}),
|
||||
);
|
||||
expect(get(toasts).length).toBe(1);
|
||||
|
||||
// 500ms later — still inside throttle
|
||||
vi.advanceTimersByTime(500);
|
||||
|
||||
emit(
|
||||
makeEvent('ConnectionDiscovered', {
|
||||
connection_type: 'causal',
|
||||
weight: 0.9,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(get(toasts).length).toBe(1);
|
||||
});
|
||||
|
||||
it('two ConnectionDiscovered more than 1500ms apart → both toasts', async () => {
|
||||
const { toasts } = await loadToastStore();
|
||||
|
||||
emit(
|
||||
makeEvent('ConnectionDiscovered', {
|
||||
connection_type: 'semantic',
|
||||
weight: 0.8,
|
||||
}),
|
||||
);
|
||||
expect(get(toasts).length).toBe(1);
|
||||
|
||||
// Wait past the throttle window (1500ms).
|
||||
vi.advanceTimersByTime(1600);
|
||||
|
||||
emit(
|
||||
makeEvent('ConnectionDiscovered', {
|
||||
connection_type: 'causal',
|
||||
weight: 0.9,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(get(toasts).length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Hover-panic — pauseDwell / resumeDwell
|
||||
// ---------------------------------------------------------------
|
||||
describe('hover-panic (pauseDwell / resumeDwell)', () => {
|
||||
it('auto-dismiss fires after dwellMs when not paused', async () => {
|
||||
const { toasts } = await loadToastStore();
|
||||
|
||||
emit(makeEvent('MemoryPromoted', { new_retention: 0.8 }));
|
||||
const t = get(toasts)[0];
|
||||
expect(t).toBeDefined();
|
||||
expect(t.dwellMs).toBe(4500);
|
||||
|
||||
// Advance just past the dwell — toast should be gone.
|
||||
vi.advanceTimersByTime(4600);
|
||||
expect(get(toasts).length).toBe(0);
|
||||
});
|
||||
|
||||
it('pauseDwell stops the auto-dismiss — toast survives past natural dwellMs', async () => {
|
||||
const { toasts } = await loadToastStore();
|
||||
|
||||
emit(makeEvent('MemoryPromoted', { new_retention: 0.8 }));
|
||||
const t = get(toasts)[0];
|
||||
|
||||
// 1 second in, pause.
|
||||
vi.advanceTimersByTime(1000);
|
||||
toasts.pauseDwell(t.id, t.dwellMs);
|
||||
|
||||
// Advance WAY past the natural dwell — still there.
|
||||
vi.advanceTimersByTime(10_000);
|
||||
expect(get(toasts).length).toBe(1);
|
||||
expect(get(toasts)[0].id).toBe(t.id);
|
||||
});
|
||||
|
||||
it('resumeDwell schedules dismissal for the REMAINING time, not the full dwellMs', async () => {
|
||||
const { toasts } = await loadToastStore();
|
||||
|
||||
emit(makeEvent('MemoryPromoted', { new_retention: 0.8 }));
|
||||
const t = get(toasts)[0];
|
||||
expect(t.dwellMs).toBe(4500);
|
||||
|
||||
// 1 second elapsed — pause. Remaining should be ~3500ms.
|
||||
vi.advanceTimersByTime(1000);
|
||||
toasts.pauseDwell(t.id, t.dwellMs);
|
||||
|
||||
// Hold paused for 10s (irrelevant to remaining calc).
|
||||
vi.advanceTimersByTime(10_000);
|
||||
|
||||
// Resume — remaining is ~3500ms.
|
||||
toasts.resumeDwell(t.id);
|
||||
|
||||
// At 3400ms still alive (just under remaining).
|
||||
vi.advanceTimersByTime(3400);
|
||||
expect(get(toasts).length).toBe(1);
|
||||
|
||||
// At 3500ms+, dismissed.
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(get(toasts).length).toBe(0);
|
||||
});
|
||||
|
||||
it('double-pause is a safe no-op (second call does not corrupt remaining)', async () => {
|
||||
const { toasts } = await loadToastStore();
|
||||
|
||||
emit(makeEvent('MemoryPromoted', { new_retention: 0.8 }));
|
||||
const t = get(toasts)[0];
|
||||
|
||||
vi.advanceTimersByTime(500);
|
||||
toasts.pauseDwell(t.id, t.dwellMs);
|
||||
|
||||
// Second pause should not throw or mutate state badly. The
|
||||
// implementation bails early because dwellTimers no longer
|
||||
// contains the id.
|
||||
expect(() => toasts.pauseDwell(t.id, t.dwellMs)).not.toThrow();
|
||||
|
||||
// Still paused — advancing doesn't dismiss.
|
||||
vi.advanceTimersByTime(10_000);
|
||||
expect(get(toasts).length).toBe(1);
|
||||
});
|
||||
|
||||
it('dismiss while paused clears the paused state (no zombie timer)', async () => {
|
||||
const { toasts } = await loadToastStore();
|
||||
|
||||
emit(makeEvent('MemoryPromoted', { new_retention: 0.8 }));
|
||||
const t = get(toasts)[0];
|
||||
|
||||
vi.advanceTimersByTime(500);
|
||||
toasts.pauseDwell(t.id, t.dwellMs);
|
||||
|
||||
// Programmatic dismiss.
|
||||
toasts.dismiss(t.id);
|
||||
expect(get(toasts).length).toBe(0);
|
||||
|
||||
// A later resume should be a no-op (no zombie re-schedule).
|
||||
toasts.resumeDwell(t.id);
|
||||
vi.advanceTimersByTime(10_000);
|
||||
expect(get(toasts).length).toBe(0);
|
||||
});
|
||||
|
||||
it('resumeDwell on a non-paused id is a no-op', async () => {
|
||||
const { toasts } = await loadToastStore();
|
||||
|
||||
emit(makeEvent('MemoryPromoted', { new_retention: 0.8 }));
|
||||
const t = get(toasts)[0];
|
||||
|
||||
// Without pausing — resume is a no-op and must not schedule
|
||||
// anything new. The original timer is still ticking.
|
||||
toasts.resumeDwell(t.id);
|
||||
|
||||
vi.advanceTimersByTime(4600);
|
||||
expect(get(toasts).length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Queue behavior
|
||||
// ---------------------------------------------------------------
|
||||
describe('queue behavior', () => {
|
||||
it('MAX_VISIBLE=4: creating a 5th toast evicts the oldest', async () => {
|
||||
const { toasts } = await loadToastStore();
|
||||
|
||||
// Use MemoryPromoted (not throttled) to stack 5 toasts.
|
||||
for (let i = 0; i < 5; i++) {
|
||||
emit(makeEvent('MemoryPromoted', { new_retention: 0.5 + i * 0.01 }));
|
||||
// Small advance so each event has a distinct identity and no batch-merge.
|
||||
vi.advanceTimersByTime(10);
|
||||
}
|
||||
|
||||
const list = get(toasts);
|
||||
expect(list.length).toBe(4);
|
||||
|
||||
// IDs are assigned 1..5 in event-processing order. Store prepends,
|
||||
// so the queue is [id=5, id=4, id=3, id=2]; id=1 was evicted.
|
||||
const ids = list.map((t) => t.id);
|
||||
expect(ids).not.toContain(1);
|
||||
expect(ids).toContain(5);
|
||||
});
|
||||
|
||||
it('clear() dismisses all toasts and cancels all timers', async () => {
|
||||
const { toasts } = await loadToastStore();
|
||||
|
||||
emit([
|
||||
makeEvent('MemoryPromoted', { new_retention: 0.8 }),
|
||||
makeEvent('MemoryDemoted', { new_retention: 0.4 }),
|
||||
]);
|
||||
expect(get(toasts).length).toBe(2);
|
||||
|
||||
toasts.clear();
|
||||
expect(get(toasts).length).toBe(0);
|
||||
|
||||
// Advancing past the dwell must not re-fire anything (no zombie timers).
|
||||
vi.advanceTimersByTime(10_000);
|
||||
expect(get(toasts).length).toBe(0);
|
||||
});
|
||||
|
||||
it('dismissing a specific id leaves the other toasts intact', async () => {
|
||||
const { toasts } = await loadToastStore();
|
||||
|
||||
emit(makeEvent('MemoryPromoted', { new_retention: 0.8 }));
|
||||
vi.advanceTimersByTime(10);
|
||||
emit(makeEvent('MemoryDemoted', { new_retention: 0.4 }));
|
||||
|
||||
const list = get(toasts);
|
||||
expect(list.length).toBe(2);
|
||||
const firstId = list[list.length - 1].id; // oldest
|
||||
|
||||
toasts.dismiss(firstId);
|
||||
|
||||
const remaining = get(toasts);
|
||||
expect(remaining.length).toBe(1);
|
||||
expect(remaining[0].id).not.toBe(firstId);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Demo sequence
|
||||
// ---------------------------------------------------------------
|
||||
describe('fireDemoSequence', () => {
|
||||
it('schedules 4 toasts staggered by 800ms', async () => {
|
||||
const { toasts, fireDemoSequence } = await loadToastStore();
|
||||
|
||||
fireDemoSequence();
|
||||
|
||||
// t=0: nothing yet (all are setTimeout, even the first at i=0 * 800 = 0ms).
|
||||
// setTimeout(_, 0) still goes to the next tick under fake timers.
|
||||
expect(get(toasts).length).toBe(0);
|
||||
|
||||
// Flush i=0.
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(get(toasts).length).toBe(1);
|
||||
expect(get(toasts)[0].type).toBe('DreamCompleted');
|
||||
|
||||
// i=1 at 800ms.
|
||||
vi.advanceTimersByTime(800);
|
||||
expect(get(toasts).length).toBe(2);
|
||||
expect(get(toasts)[0].type).toBe('ConnectionDiscovered');
|
||||
|
||||
// i=2 at 1600ms.
|
||||
vi.advanceTimersByTime(800);
|
||||
expect(get(toasts).length).toBe(3);
|
||||
expect(get(toasts)[0].type).toBe('MemorySuppressed');
|
||||
|
||||
// i=3 at 2400ms.
|
||||
vi.advanceTimersByTime(800);
|
||||
expect(get(toasts).length).toBe(4);
|
||||
expect(get(toasts)[0].type).toBe('ConsolidationCompleted');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Toast shape sanity
|
||||
// ---------------------------------------------------------------
|
||||
describe('toast shape', () => {
|
||||
it('each toast has id, createdAt, color, dwellMs fields populated', async () => {
|
||||
const { toasts } = await loadToastStore();
|
||||
|
||||
emit(makeEvent('MemoryPromoted', { new_retention: 0.9 }));
|
||||
|
||||
const t = get(toasts)[0];
|
||||
expect(t.id).toBeTypeOf('number');
|
||||
expect(t.createdAt).toBeTypeOf('number');
|
||||
expect(t.color).toBeTypeOf('string');
|
||||
expect(t.dwellMs).toBeTypeOf('number');
|
||||
expect(t.color.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('ids are strictly increasing across successive toasts', async () => {
|
||||
const { toasts } = await loadToastStore();
|
||||
|
||||
emit(makeEvent('MemoryPromoted', { new_retention: 0.9 }));
|
||||
vi.advanceTimersByTime(10);
|
||||
emit(makeEvent('MemoryDemoted', { new_retention: 0.3 }));
|
||||
|
||||
const list = get(toasts);
|
||||
expect(list.length).toBe(2);
|
||||
// Store prepends, so list[0] is newer = higher id.
|
||||
expect(list[0].id).toBeGreaterThan(list[1].id);
|
||||
});
|
||||
});
|
||||
});
|
||||
341
apps/dashboard/src/lib/stores/__tests__/websocket.test.ts
Normal file
341
apps/dashboard/src/lib/stores/__tests__/websocket.test.ts
Normal file
|
|
@ -0,0 +1,341 @@
|
|||
/**
|
||||
* Unit tests for the websocket store.
|
||||
*
|
||||
* Scope: pure-store methods and derived-store behavior that can be tested
|
||||
* without a real WebSocket connection. Connection lifecycle, reconnect
|
||||
* backoff, and live handler wiring are out of scope — those are integration
|
||||
* concerns.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
// Stub the global WebSocket BEFORE importing the store, so any accidental
|
||||
// `connect()` path does not attempt a real network call and does not throw.
|
||||
// The FakeWS also captures the most-recently-constructed instance so tests
|
||||
// that want to drive `onmessage` (to exercise the Heartbeat branch of the
|
||||
// internal update handler) can do so.
|
||||
class FakeWS {
|
||||
static last: FakeWS | null = null;
|
||||
static OPEN = 1;
|
||||
readyState = 0;
|
||||
onopen: ((ev?: unknown) => void) | null = null;
|
||||
onclose: ((ev?: unknown) => void) | null = null;
|
||||
onmessage: ((ev: { data: string }) => void) | null = null;
|
||||
onerror: ((ev?: unknown) => void) | null = null;
|
||||
constructor(public url: string) {
|
||||
FakeWS.last = this;
|
||||
}
|
||||
close() {
|
||||
/* no-op */
|
||||
}
|
||||
addEventListener() {
|
||||
/* no-op */
|
||||
}
|
||||
removeEventListener() {
|
||||
/* no-op */
|
||||
}
|
||||
}
|
||||
vi.stubGlobal('WebSocket', FakeWS);
|
||||
|
||||
import {
|
||||
websocket,
|
||||
eventFeed,
|
||||
isConnected,
|
||||
memoryCount,
|
||||
avgRetention,
|
||||
suppressedCount,
|
||||
uptimeSeconds,
|
||||
heartbeat,
|
||||
formatUptime,
|
||||
} from '../websocket';
|
||||
import type { VestigeEvent } from '$types';
|
||||
|
||||
const MAX_EVENTS = 200;
|
||||
|
||||
function makeEvent(
|
||||
type: VestigeEvent['type'] = 'MemoryCreated',
|
||||
data: Record<string, unknown> = {}
|
||||
): VestigeEvent {
|
||||
return { type, data };
|
||||
}
|
||||
|
||||
function makeHeartbeat(data: Record<string, unknown> = {}): VestigeEvent {
|
||||
return {
|
||||
type: 'Heartbeat',
|
||||
data: {
|
||||
memory_count: 0,
|
||||
avg_retention: 0,
|
||||
suppressed_count: 0,
|
||||
uptime_secs: 0,
|
||||
...data,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: drive a heartbeat into the store via the internal onmessage path.
|
||||
* We cannot reach `update()` directly, and `injectEvent()` deliberately
|
||||
* does NOT treat heartbeats specially, so the only way to populate
|
||||
* `lastHeartbeat` is to route through the WebSocket handler.
|
||||
*/
|
||||
function deliverHeartbeat(hb: VestigeEvent) {
|
||||
// Disconnect to reset any prior state, then connect to install handlers
|
||||
// on a fresh FakeWS instance whose onmessage we can invoke.
|
||||
websocket.disconnect();
|
||||
websocket.connect('ws://test.invalid/ws');
|
||||
const ws = FakeWS.last;
|
||||
if (!ws || !ws.onmessage) throw new Error('FakeWS onmessage not wired');
|
||||
ws.onmessage({ data: JSON.stringify(hb) });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset the events array between tests; lastHeartbeat is explicitly left
|
||||
// alone here because `clearEvents()` preserves it (that is itself tested
|
||||
// below). For the derived-store defaults tests we call disconnect() to
|
||||
// fully reset the store.
|
||||
websocket.clearEvents();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// injectEvent
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('injectEvent', () => {
|
||||
it('adds a single event at index 0', () => {
|
||||
const evt = makeEvent('MemoryCreated', { id: 'a' });
|
||||
websocket.injectEvent(evt);
|
||||
const feed = get(eventFeed);
|
||||
expect(feed.length).toBe(1);
|
||||
expect(feed[0]).toEqual(evt);
|
||||
});
|
||||
|
||||
it('prepends: newest injected ends up at index 0', () => {
|
||||
const first = makeEvent('MemoryCreated', { id: 'first' });
|
||||
const second = makeEvent('MemoryUpdated', { id: 'second' });
|
||||
const third = makeEvent('MemoryDeleted', { id: 'third' });
|
||||
websocket.injectEvent(first);
|
||||
websocket.injectEvent(second);
|
||||
websocket.injectEvent(third);
|
||||
const feed = get(eventFeed);
|
||||
expect(feed.length).toBe(3);
|
||||
expect(feed[0]).toEqual(third);
|
||||
expect(feed[1]).toEqual(second);
|
||||
expect(feed[2]).toEqual(first);
|
||||
});
|
||||
|
||||
it('caps the events array at MAX_EVENTS (200)', () => {
|
||||
for (let i = 0; i < MAX_EVENTS + 50; i++) {
|
||||
websocket.injectEvent(makeEvent('MemoryCreated', { seq: i }));
|
||||
}
|
||||
const feed = get(eventFeed);
|
||||
expect(feed.length).toBe(MAX_EVENTS);
|
||||
});
|
||||
|
||||
it('evicts the oldest entry when at capacity (FIFO drop)', () => {
|
||||
// Fill to exactly capacity, then push one more: seq=0 should be gone.
|
||||
for (let i = 0; i < MAX_EVENTS; i++) {
|
||||
websocket.injectEvent(makeEvent('MemoryCreated', { seq: i }));
|
||||
}
|
||||
websocket.injectEvent(makeEvent('MemoryCreated', { seq: 999 }));
|
||||
const feed = get(eventFeed);
|
||||
expect(feed.length).toBe(MAX_EVENTS);
|
||||
expect(feed[0].data.seq).toBe(999);
|
||||
// Oldest (seq=0) evicted; tail is now the prior second-oldest (seq=1).
|
||||
expect(feed[feed.length - 1].data.seq).toBe(1);
|
||||
expect(feed.some((e) => e.data.seq === 0)).toBe(false);
|
||||
});
|
||||
|
||||
it('triggers the eventFeed derived store to emit on each injection', () => {
|
||||
const observed: number[] = [];
|
||||
const unsub = eventFeed.subscribe((events) => {
|
||||
observed.push(events.length);
|
||||
});
|
||||
// Initial subscription fires once with current length (0 after beforeEach).
|
||||
const initialEmitCount = observed.length;
|
||||
websocket.injectEvent(makeEvent());
|
||||
websocket.injectEvent(makeEvent());
|
||||
unsub();
|
||||
// Two injections should produce two additional emits beyond the initial one.
|
||||
expect(observed.length).toBe(initialEmitCount + 2);
|
||||
expect(observed[observed.length - 1]).toBe(2);
|
||||
});
|
||||
|
||||
it('does NOT treat Heartbeat-typed events specially when injected', () => {
|
||||
// Documented behavior: injectEvent is a raw prepend. Only the real
|
||||
// onmessage handler branches on type === 'Heartbeat'. If a caller
|
||||
// injects a Heartbeat, it lands in the events array, and lastHeartbeat
|
||||
// is untouched. Callers who want a heartbeat-like derived-store update
|
||||
// must route through the WebSocket handler instead.
|
||||
websocket.disconnect(); // reset lastHeartbeat to null
|
||||
const hb = makeHeartbeat({ memory_count: 42 });
|
||||
websocket.injectEvent(hb);
|
||||
const feed = get(eventFeed);
|
||||
expect(feed.length).toBe(1);
|
||||
expect(feed[0]).toEqual(hb);
|
||||
// memoryCount still 0 because lastHeartbeat was never written.
|
||||
expect(get(memoryCount)).toBe(0);
|
||||
expect(get(heartbeat)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Derived store defaults (no heartbeat yet)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('derived stores — defaults with no heartbeat', () => {
|
||||
beforeEach(() => {
|
||||
// Full reset so lastHeartbeat is null.
|
||||
websocket.disconnect();
|
||||
});
|
||||
|
||||
it('isConnected is false after disconnect', () => {
|
||||
expect(get(isConnected)).toBe(false);
|
||||
});
|
||||
|
||||
it('heartbeat is null when no heartbeat has arrived', () => {
|
||||
expect(get(heartbeat)).toBeNull();
|
||||
});
|
||||
|
||||
it('memoryCount returns 0 when no heartbeat has arrived', () => {
|
||||
expect(get(memoryCount)).toBe(0);
|
||||
});
|
||||
|
||||
it('avgRetention returns 0 when no heartbeat has arrived', () => {
|
||||
expect(get(avgRetention)).toBe(0);
|
||||
});
|
||||
|
||||
it('suppressedCount returns 0 when no heartbeat has arrived', () => {
|
||||
expect(get(suppressedCount)).toBe(0);
|
||||
});
|
||||
|
||||
it('uptimeSeconds returns 0 when no heartbeat has arrived', () => {
|
||||
expect(get(uptimeSeconds)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Derived stores after heartbeat
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('derived stores — after heartbeat delivery', () => {
|
||||
it('memoryCount, avgRetention, suppressedCount, uptimeSeconds all update', () => {
|
||||
deliverHeartbeat(
|
||||
makeHeartbeat({
|
||||
memory_count: 123,
|
||||
avg_retention: 0.74,
|
||||
suppressed_count: 5,
|
||||
uptime_secs: 3661,
|
||||
})
|
||||
);
|
||||
expect(get(memoryCount)).toBe(123);
|
||||
expect(get(avgRetention)).toBeCloseTo(0.74);
|
||||
expect(get(suppressedCount)).toBe(5);
|
||||
expect(get(uptimeSeconds)).toBe(3661);
|
||||
const hb = get(heartbeat);
|
||||
expect(hb).not.toBeNull();
|
||||
expect(hb?.type).toBe('Heartbeat');
|
||||
});
|
||||
|
||||
it('heartbeat events do NOT enter the events array (handled by onmessage)', () => {
|
||||
websocket.disconnect();
|
||||
deliverHeartbeat(makeHeartbeat({ memory_count: 1 }));
|
||||
expect(get(eventFeed).length).toBe(0);
|
||||
});
|
||||
|
||||
it('non-heartbeat events delivered via onmessage enter the events array', () => {
|
||||
websocket.disconnect();
|
||||
websocket.connect('ws://test.invalid/ws');
|
||||
const ws = FakeWS.last!;
|
||||
ws.onmessage!({ data: JSON.stringify(makeEvent('MemoryCreated', { id: 'x' })) });
|
||||
const feed = get(eventFeed);
|
||||
expect(feed.length).toBe(1);
|
||||
expect(feed[0].type).toBe('MemoryCreated');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// clearEvents
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('clearEvents', () => {
|
||||
it('empties the events array', () => {
|
||||
websocket.injectEvent(makeEvent());
|
||||
websocket.injectEvent(makeEvent());
|
||||
expect(get(eventFeed).length).toBe(2);
|
||||
websocket.clearEvents();
|
||||
expect(get(eventFeed).length).toBe(0);
|
||||
});
|
||||
|
||||
it('preserves lastHeartbeat (does NOT clear it)', () => {
|
||||
deliverHeartbeat(makeHeartbeat({ memory_count: 77 }));
|
||||
expect(get(memoryCount)).toBe(77);
|
||||
websocket.injectEvent(makeEvent('MemoryCreated'));
|
||||
websocket.clearEvents();
|
||||
expect(get(eventFeed).length).toBe(0);
|
||||
// lastHeartbeat untouched, so memoryCount still reflects the heartbeat.
|
||||
expect(get(memoryCount)).toBe(77);
|
||||
expect(get(heartbeat)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// formatUptime
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('formatUptime', () => {
|
||||
it("returns '—' for negative input", () => {
|
||||
expect(formatUptime(-1)).toBe('—');
|
||||
});
|
||||
|
||||
it("returns '—' for non-finite input (NaN, Infinity)", () => {
|
||||
expect(formatUptime(NaN)).toBe('—');
|
||||
expect(formatUptime(Infinity)).toBe('—');
|
||||
expect(formatUptime(-Infinity)).toBe('—');
|
||||
});
|
||||
|
||||
it("returns '0s' for 0 (boundary: non-negative, all units zero)", () => {
|
||||
// secs=0 is NOT < 0, so it falls through to the '${s}s' branch.
|
||||
expect(formatUptime(0)).toBe('0s');
|
||||
});
|
||||
|
||||
it('seconds-only branch: 47s', () => {
|
||||
expect(formatUptime(47)).toBe('47s');
|
||||
});
|
||||
|
||||
it('seconds boundary: 59s → "59s"', () => {
|
||||
expect(formatUptime(59)).toBe('59s');
|
||||
});
|
||||
|
||||
it('minute boundary: 60s → "1m" (no trailing 0s)', () => {
|
||||
expect(formatUptime(60)).toBe('1m');
|
||||
});
|
||||
|
||||
it('minutes + seconds: 190s → "3m 10s"', () => {
|
||||
expect(formatUptime(190)).toBe('3m 10s');
|
||||
});
|
||||
|
||||
it('hour boundary minus one: 3599s → "59m 59s"', () => {
|
||||
expect(formatUptime(3599)).toBe('59m 59s');
|
||||
});
|
||||
|
||||
it('hour boundary: 3600s → "1h" (no trailing 0m)', () => {
|
||||
expect(formatUptime(3600)).toBe('1h');
|
||||
});
|
||||
|
||||
it('hours + minutes: 11520s (3h 12m) → "3h 12m"', () => {
|
||||
expect(formatUptime(3 * 3600 + 12 * 60)).toBe('3h 12m');
|
||||
});
|
||||
|
||||
it('day boundary minus one: 86399s → "23h 59m"', () => {
|
||||
// Two-most-significant-units rule: hours + minutes, seconds dropped.
|
||||
expect(formatUptime(86399)).toBe('23h 59m');
|
||||
});
|
||||
|
||||
it('day boundary: 86400s → "1d" (no trailing 0h)', () => {
|
||||
expect(formatUptime(86400)).toBe('1d');
|
||||
});
|
||||
|
||||
it('days + hours: 4d 2h → "4d 2h" (minutes dropped)', () => {
|
||||
expect(formatUptime(4 * 86400 + 2 * 3600 + 37 * 60)).toBe('4d 2h');
|
||||
});
|
||||
});
|
||||
|
|
@ -63,7 +63,21 @@ export const api = {
|
|||
fetcher<TimelineResponse>(`/timeline?days=${days}&limit=${limit}`),
|
||||
|
||||
// Graph
|
||||
graph: (params?: { query?: string; center_id?: string; depth?: number; max_nodes?: number }) => {
|
||||
//
|
||||
// `sort` controls the default center when no query/center_id is given:
|
||||
// - "recent" (default) — newest memory; matches user expectation of
|
||||
// "show me what I just added". Previously the backend defaulted to
|
||||
// "connected" which clustered on historical hotspots and hid
|
||||
// fresh memories that hadn't accumulated edges yet.
|
||||
// - "connected" — densest node; richer initial subgraph for a
|
||||
// well-aged corpus. Exposed for a future UI toggle.
|
||||
graph: (params?: {
|
||||
query?: string;
|
||||
center_id?: string;
|
||||
depth?: number;
|
||||
max_nodes?: number;
|
||||
sort?: 'recent' | 'connected';
|
||||
}) => {
|
||||
const qs = params ? '?' + new URLSearchParams(
|
||||
Object.entries(params)
|
||||
.filter(([, v]) => v !== undefined)
|
||||
|
|
@ -95,5 +109,15 @@ export const api = {
|
|||
|
||||
// Intentions
|
||||
intentions: (status = 'active') =>
|
||||
fetcher<{ intentions: IntentionItem[]; total: number; filter: string }>(`/intentions?status=${status}`)
|
||||
fetcher<{ intentions: IntentionItem[]; total: number; filter: string }>(`/intentions?status=${status}`),
|
||||
|
||||
// Reasoning Theater (v2.0.8): the 8-stage deep_reference cognitive pipeline.
|
||||
// Returns a reasoning chain + evidence + contradictions + supersession +
|
||||
// evolution + confidence. Emits DeepReferenceCompleted on the WebSocket so
|
||||
// the 3D graph can camera-glide + pulse + arc.
|
||||
deepReference: (query: string, depth = 20) =>
|
||||
fetcher<Record<string, unknown>>('/deep_reference', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ query, depth })
|
||||
})
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,19 @@
|
|||
// Shared graph state using Svelte 5 $state runes
|
||||
// This store manages temporal playback and dream mode state
|
||||
// This store manages temporal playback, dream mode, and brightness.
|
||||
|
||||
const BRIGHTNESS_KEY = 'vestige:graph:brightness';
|
||||
const BRIGHTNESS_DEFAULT = 1.0;
|
||||
const BRIGHTNESS_MIN = 0.5;
|
||||
const BRIGHTNESS_MAX = 2.5;
|
||||
|
||||
function loadBrightness(): number {
|
||||
if (typeof localStorage === 'undefined') return BRIGHTNESS_DEFAULT;
|
||||
const raw = localStorage.getItem(BRIGHTNESS_KEY);
|
||||
if (raw === null) return BRIGHTNESS_DEFAULT;
|
||||
const n = Number(raw);
|
||||
if (!Number.isFinite(n)) return BRIGHTNESS_DEFAULT;
|
||||
return Math.min(BRIGHTNESS_MAX, Math.max(BRIGHTNESS_MIN, n));
|
||||
}
|
||||
|
||||
export const graphState = createGraphState();
|
||||
|
||||
|
|
@ -9,6 +23,7 @@ function createGraphState() {
|
|||
let temporalPlaying = $state(false);
|
||||
let temporalSpeed = $state(1); // days per second: 1, 7, 30
|
||||
let dreamMode = $state(false);
|
||||
let brightness = $state(loadBrightness());
|
||||
|
||||
return {
|
||||
get temporalEnabled() {
|
||||
|
|
@ -45,5 +60,26 @@ function createGraphState() {
|
|||
set dreamMode(v: boolean) {
|
||||
dreamMode = v;
|
||||
},
|
||||
|
||||
// Global brightness multiplier for the 3D graph. Scales node emissive
|
||||
// intensity, glow opacity, and edge opacity. Persisted in localStorage
|
||||
// so it survives reloads.
|
||||
get brightness() {
|
||||
return brightness;
|
||||
},
|
||||
set brightness(v: number) {
|
||||
const clamped = Math.min(BRIGHTNESS_MAX, Math.max(BRIGHTNESS_MIN, v));
|
||||
brightness = clamped;
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
try {
|
||||
localStorage.setItem(BRIGHTNESS_KEY, String(clamped));
|
||||
} catch {
|
||||
/* private browsing / quota — ignore */
|
||||
}
|
||||
}
|
||||
},
|
||||
brightnessMin: BRIGHTNESS_MIN,
|
||||
brightnessMax: BRIGHTNESS_MAX,
|
||||
brightnessDefault: BRIGHTNESS_DEFAULT,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
254
apps/dashboard/src/lib/stores/theme.ts
Normal file
254
apps/dashboard/src/lib/stores/theme.ts
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
// Theme store — closes GitHub issue #11 (Dark/Light theme toggle).
|
||||
//
|
||||
// Design:
|
||||
// - Three modes: 'dark' (current default, bioluminescent), 'light'
|
||||
// (slate-on-white, muted glow), 'auto' (follows system preference).
|
||||
// - A single writable `theme` store holds the user preference.
|
||||
// A derived `resolvedTheme` collapses 'auto' into the concrete 'dark' |
|
||||
// 'light' that matchMedia is reporting right now.
|
||||
// - On any change we flip `document.documentElement.dataset.theme`. All
|
||||
// CSS variable overrides key off `[data-theme='light']` in the
|
||||
// injected stylesheet below (app.css is deliberately left untouched so
|
||||
// the dark defaults still cascade when no attribute is set).
|
||||
// - Preference persists to `localStorage['vestige.theme']`.
|
||||
// - `initTheme()` is called once from +layout.svelte onMount. It (a)
|
||||
// reads localStorage, (b) injects the light-mode stylesheet into
|
||||
// <head>, (c) sets dataset.theme, (d) attaches a matchMedia listener
|
||||
// so 'auto' tracks the OS in real time.
|
||||
//
|
||||
// Light-mode override strategy:
|
||||
// We inject a single <style id="vestige-theme-light"> block at init time
|
||||
// rather than editing app.css. This keeps the dark-first design pristine
|
||||
// and lets us ship the toggle as a purely additive change. Overrides
|
||||
// target the real token names used in app.css (`--color-void`,
|
||||
// `--color-text`, `--color-bright`, `--color-dim`, `--color-muted`,
|
||||
// `--color-surface`, etc.) plus halve the glow shadows so neon accents
|
||||
// don't wash out on a slate-50 canvas.
|
||||
|
||||
import { writable, derived, get, type Readable } from 'svelte/store';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
export type Theme = 'dark' | 'light' | 'auto';
|
||||
export type ResolvedTheme = 'dark' | 'light';
|
||||
|
||||
const STORAGE_KEY = 'vestige.theme';
|
||||
const STYLE_ELEMENT_ID = 'vestige-theme-light';
|
||||
|
||||
/** User preference — 'dark' | 'light' | 'auto'. Persists to localStorage. */
|
||||
export const theme = writable<Theme>('dark');
|
||||
|
||||
/**
|
||||
* System preference at this moment — tracked via matchMedia and kept in
|
||||
* sync by the listener wired up in `initTheme`. Defaults to 'dark' so
|
||||
* SSR/first paint matches the dark-first design.
|
||||
*/
|
||||
const systemPrefersDark = writable<boolean>(true);
|
||||
|
||||
/**
|
||||
* The concrete theme after resolving 'auto' → matchMedia. This is what
|
||||
* actually gets written to `document.documentElement.dataset.theme`.
|
||||
*/
|
||||
export const resolvedTheme: Readable<ResolvedTheme> = derived(
|
||||
[theme, systemPrefersDark],
|
||||
([$theme, $prefersDark]) => {
|
||||
if ($theme === 'auto') return $prefersDark ? 'dark' : 'light';
|
||||
return $theme;
|
||||
}
|
||||
);
|
||||
|
||||
/** Runtime guard — TypeScript callers are already narrowed, but the store is
|
||||
* also exposed via the dashboard window for devtools / demo sequences.
|
||||
* We silently ignore unknown values rather than throwing so a fat-finger
|
||||
* console poke can't wedge the UI. */
|
||||
function isValidTheme(v: unknown): v is Theme {
|
||||
return v === 'dark' || v === 'light' || v === 'auto';
|
||||
}
|
||||
|
||||
/** Public setter. Also persists to localStorage. Invalid inputs are ignored. */
|
||||
export function setTheme(next: Theme): void {
|
||||
if (!isValidTheme(next)) return;
|
||||
theme.set(next);
|
||||
if (browser) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, next);
|
||||
} catch {
|
||||
// Private mode / disabled storage — silent no-op.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Cycle dark → light → auto → dark. Used by the ThemeToggle button. */
|
||||
export function cycleTheme(): void {
|
||||
const current = get(theme);
|
||||
const next: Theme = current === 'dark' ? 'light' : current === 'light' ? 'auto' : 'dark';
|
||||
setTheme(next);
|
||||
}
|
||||
|
||||
/**
|
||||
* Injects the light-mode variable overrides into <head>. Idempotent —
|
||||
* safe to call multiple times. We target the real tokens from app.css
|
||||
* and halve the glow intensity so bioluminescent accents remain readable
|
||||
* but don't bloom on a pale canvas.
|
||||
*/
|
||||
function ensureLightStylesheet(): void {
|
||||
if (!browser) return;
|
||||
if (document.getElementById(STYLE_ELEMENT_ID)) return;
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.id = STYLE_ELEMENT_ID;
|
||||
style.textContent = `
|
||||
/* Vestige light-mode overrides — injected by theme.ts.
|
||||
* Activated by [data-theme='light'] on <html>.
|
||||
* Tokens mirror the real names used in app.css so the cascade stays clean. */
|
||||
[data-theme='light'] {
|
||||
/* Core surface palette (slate scale) */
|
||||
--color-void: #f8fafc; /* slate-50 — page background */
|
||||
--color-abyss: #f1f5f9; /* slate-100 */
|
||||
--color-deep: #e2e8f0; /* slate-200 */
|
||||
--color-surface: #f1f5f9; /* slate-100 */
|
||||
--color-elevated: #e2e8f0; /* slate-200 */
|
||||
--color-subtle: #cbd5e1; /* slate-300 */
|
||||
--color-muted: #94a3b8; /* slate-400 */
|
||||
--color-dim: #475569; /* slate-600 */
|
||||
--color-text: #0f172a; /* slate-900 */
|
||||
--color-bright: #020617; /* slate-950 */
|
||||
}
|
||||
|
||||
/* Baseline body/html wiring — app.css sets these against the dark
|
||||
* tokens; we just let the variables do the work. Reassert for clarity. */
|
||||
[data-theme='light'] html,
|
||||
html[data-theme='light'] {
|
||||
background: var(--color-void);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* Glass surfaces — recompose on a light canvas. The original alphas
|
||||
* are tuned for dark; invert-and-tint for light so panels still read
|
||||
* as elevated instead of vanishing. */
|
||||
[data-theme='light'] .glass {
|
||||
background: rgba(255, 255, 255, 0.65);
|
||||
border: 1px solid rgba(99, 102, 241, 0.12);
|
||||
box-shadow:
|
||||
inset 0 1px 0 0 rgba(255, 255, 255, 0.6),
|
||||
0 4px 24px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
[data-theme='light'] .glass-subtle {
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
border: 1px solid rgba(99, 102, 241, 0.1);
|
||||
box-shadow:
|
||||
inset 0 1px 0 0 rgba(255, 255, 255, 0.5),
|
||||
0 2px 12px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
[data-theme='light'] .glass-sidebar {
|
||||
background: rgba(248, 250, 252, 0.82);
|
||||
border-right: 1px solid rgba(99, 102, 241, 0.14);
|
||||
box-shadow:
|
||||
inset -1px 0 0 0 rgba(255, 255, 255, 0.4),
|
||||
4px 0 24px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
[data-theme='light'] .glass-panel {
|
||||
background: rgba(255, 255, 255, 0.75);
|
||||
border: 1px solid rgba(99, 102, 241, 0.14);
|
||||
box-shadow:
|
||||
inset 0 1px 0 0 rgba(255, 255, 255, 0.5),
|
||||
0 8px 32px rgba(15, 23, 42, 0.1);
|
||||
}
|
||||
|
||||
/* Halve glow intensity — neon accents stay recognizable without
|
||||
* washing out on slate-50. */
|
||||
[data-theme='light'] .glow-synapse {
|
||||
box-shadow: 0 0 10px rgba(99, 102, 241, 0.15), 0 0 30px rgba(99, 102, 241, 0.05);
|
||||
}
|
||||
[data-theme='light'] .glow-dream {
|
||||
box-shadow: 0 0 10px rgba(168, 85, 247, 0.15), 0 0 30px rgba(168, 85, 247, 0.05);
|
||||
}
|
||||
[data-theme='light'] .glow-memory {
|
||||
box-shadow: 0 0 10px rgba(59, 130, 246, 0.15), 0 0 30px rgba(59, 130, 246, 0.05);
|
||||
}
|
||||
|
||||
/* Ambient orbs are gorgeous on black and blinding on white. Tame them. */
|
||||
[data-theme='light'] .ambient-orb {
|
||||
opacity: 0.18;
|
||||
filter: blur(100px);
|
||||
}
|
||||
|
||||
/* Scrollbar recolor for the lighter surface. */
|
||||
[data-theme='light'] ::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
}
|
||||
[data-theme='light'] ::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
/** Apply the resolved theme to <html> so CSS selectors activate. */
|
||||
function applyDocumentAttribute(resolved: ResolvedTheme): void {
|
||||
if (!browser) return;
|
||||
document.documentElement.dataset.theme = resolved;
|
||||
}
|
||||
|
||||
let mediaQuery: MediaQueryList | null = null;
|
||||
let mediaListener: ((e: MediaQueryListEvent) => void) | null = null;
|
||||
let themeUnsub: (() => void) | null = null;
|
||||
let resolvedUnsub: (() => void) | null = null;
|
||||
|
||||
/**
|
||||
* Boot the theme system. Call once from +layout.svelte onMount.
|
||||
* Idempotent — safe to call repeatedly; subsequent calls are no-ops.
|
||||
* Returns a teardown fn for tests / HMR.
|
||||
*/
|
||||
export function initTheme(): () => void {
|
||||
if (!browser) return () => {};
|
||||
|
||||
// Tear down any prior init so repeated calls don't leak listeners or
|
||||
// subscriptions. This is the hot-reload / double-mount safety net.
|
||||
if (mediaQuery && mediaListener) {
|
||||
mediaQuery.removeEventListener('change', mediaListener);
|
||||
}
|
||||
resolvedUnsub?.();
|
||||
themeUnsub?.();
|
||||
mediaQuery = null;
|
||||
mediaListener = null;
|
||||
resolvedUnsub = null;
|
||||
themeUnsub = null;
|
||||
|
||||
ensureLightStylesheet();
|
||||
|
||||
// 1. Read persisted preference.
|
||||
let saved: Theme = 'dark';
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw === 'dark' || raw === 'light' || raw === 'auto') saved = raw;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
theme.set(saved);
|
||||
|
||||
// 2. Prime system preference + attach matchMedia listener.
|
||||
mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
systemPrefersDark.set(mediaQuery.matches);
|
||||
mediaListener = (e: MediaQueryListEvent) => systemPrefersDark.set(e.matches);
|
||||
mediaQuery.addEventListener('change', mediaListener);
|
||||
|
||||
// 3. Apply the currently-resolved theme and subscribe for future changes.
|
||||
applyDocumentAttribute(get(resolvedTheme));
|
||||
resolvedUnsub = resolvedTheme.subscribe(applyDocumentAttribute);
|
||||
|
||||
// Silence the unused-import lint on `theme` — already used above,
|
||||
// but also keep a subscription handle for teardown symmetry.
|
||||
themeUnsub = theme.subscribe(() => {});
|
||||
|
||||
return () => {
|
||||
if (mediaQuery && mediaListener) {
|
||||
mediaQuery.removeEventListener('change', mediaListener);
|
||||
}
|
||||
mediaQuery = null;
|
||||
mediaListener = null;
|
||||
resolvedUnsub?.();
|
||||
themeUnsub?.();
|
||||
resolvedUnsub = null;
|
||||
themeUnsub = null;
|
||||
};
|
||||
}
|
||||
329
apps/dashboard/src/lib/stores/toast.ts
Normal file
329
apps/dashboard/src/lib/stores/toast.ts
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
// Pulse Toast — v2.2
|
||||
// Subscribes to the WebSocket event feed and surfaces meaningful cognitive
|
||||
// events as ephemeral toast notifications. This is the "brain coming alive"
|
||||
// moment — when the dashboard is open you SEE Vestige thinking.
|
||||
//
|
||||
// Design:
|
||||
// - Filter the spammy events (Heartbeat, SearchPerformed, RetentionDecayed,
|
||||
// ActivationSpread, ImportanceScored, MemoryCreated). Those fire during
|
||||
// every ingest cycle and would flood the UI.
|
||||
// - Surface the narrative events: Dreams completing, Consolidation sweeping,
|
||||
// Memories being promoted/demoted/suppressed, Rac1 cascades, new bridges.
|
||||
// - Rate-limit ConnectionDiscovered — dreams fire many of these in seconds.
|
||||
// - Auto-dismiss after 5-6s. Max 4 on screen.
|
||||
|
||||
import { writable, get } from 'svelte/store';
|
||||
import { eventFeed } from '$stores/websocket';
|
||||
import type { VestigeEvent, VestigeEventType } from '$types';
|
||||
import { EVENT_TYPE_COLORS } from '$types';
|
||||
|
||||
export interface Toast {
|
||||
id: number;
|
||||
type: VestigeEventType;
|
||||
title: string;
|
||||
body: string;
|
||||
color: string;
|
||||
dwellMs: number;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
const MAX_VISIBLE = 4;
|
||||
const DEFAULT_DWELL_MS = 5500;
|
||||
const CONNECTION_THROTTLE_MS = 1500;
|
||||
|
||||
function createToastStore() {
|
||||
const { subscribe, update } = writable<Toast[]>([]);
|
||||
let nextId = 1;
|
||||
let lastConnectionAt = 0;
|
||||
|
||||
// Dwell-timer registry — exposed so the component can pause on hover
|
||||
// (biological respect: don't auto-dismiss a toast the user is actively
|
||||
// reading). Paused entries store remaining ms so resume can schedule a
|
||||
// new timer for the correct duration.
|
||||
const dwellTimers = new Map<number, ReturnType<typeof setTimeout>>();
|
||||
const dwellPaused = new Map<number, { remaining: number }>();
|
||||
const dwellStart = new Map<number, number>();
|
||||
|
||||
function scheduleDismiss(id: number, ms: number) {
|
||||
dwellStart.set(id, Date.now());
|
||||
const handle = setTimeout(() => {
|
||||
dwellTimers.delete(id);
|
||||
dwellStart.delete(id);
|
||||
dismiss(id);
|
||||
}, ms);
|
||||
dwellTimers.set(id, handle);
|
||||
}
|
||||
|
||||
function push(toast: Omit<Toast, 'id' | 'createdAt'>) {
|
||||
const id = nextId++;
|
||||
const createdAt = Date.now();
|
||||
const entry: Toast = { id, createdAt, ...toast };
|
||||
update(list => {
|
||||
const next = [entry, ...list];
|
||||
if (next.length > MAX_VISIBLE) {
|
||||
return next.slice(0, MAX_VISIBLE);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
scheduleDismiss(id, toast.dwellMs);
|
||||
}
|
||||
|
||||
function dismiss(id: number) {
|
||||
const handle = dwellTimers.get(id);
|
||||
if (handle) {
|
||||
clearTimeout(handle);
|
||||
dwellTimers.delete(id);
|
||||
}
|
||||
dwellPaused.delete(id);
|
||||
dwellStart.delete(id);
|
||||
update(list => list.filter(t => t.id !== id));
|
||||
}
|
||||
|
||||
function pauseDwell(id: number, toastDwellMs: number) {
|
||||
const handle = dwellTimers.get(id);
|
||||
if (!handle) return;
|
||||
clearTimeout(handle);
|
||||
dwellTimers.delete(id);
|
||||
const startedAt = dwellStart.get(id) ?? Date.now();
|
||||
const elapsed = Date.now() - startedAt;
|
||||
const remaining = Math.max(200, toastDwellMs - elapsed);
|
||||
dwellPaused.set(id, { remaining });
|
||||
}
|
||||
|
||||
function resumeDwell(id: number) {
|
||||
const paused = dwellPaused.get(id);
|
||||
if (!paused) return;
|
||||
dwellPaused.delete(id);
|
||||
scheduleDismiss(id, paused.remaining);
|
||||
}
|
||||
|
||||
function clear() {
|
||||
for (const handle of dwellTimers.values()) clearTimeout(handle);
|
||||
dwellTimers.clear();
|
||||
dwellPaused.clear();
|
||||
dwellStart.clear();
|
||||
update(() => []);
|
||||
}
|
||||
|
||||
function translate(event: VestigeEvent): Omit<Toast, 'id' | 'createdAt'> | null {
|
||||
const color = EVENT_TYPE_COLORS[event.type] ?? '#818CF8';
|
||||
const d = event.data as Record<string, unknown>;
|
||||
|
||||
switch (event.type) {
|
||||
case 'DreamCompleted': {
|
||||
const replayed = Number(d.memories_replayed ?? 0);
|
||||
const found = Number(d.connections_found ?? 0);
|
||||
const insights = Number(d.insights_generated ?? 0);
|
||||
const ms = Number(d.duration_ms ?? 0);
|
||||
const parts: string[] = [];
|
||||
parts.push(`Replayed ${replayed} ${replayed === 1 ? 'memory' : 'memories'}`);
|
||||
if (found > 0) parts.push(`${found} new connection${found === 1 ? '' : 's'}`);
|
||||
if (insights > 0) parts.push(`${insights} insight${insights === 1 ? '' : 's'}`);
|
||||
return {
|
||||
type: event.type,
|
||||
title: 'Dream consolidated',
|
||||
body: `${parts.join(' · ')} in ${(ms / 1000).toFixed(1)}s`,
|
||||
color,
|
||||
dwellMs: 7000,
|
||||
};
|
||||
}
|
||||
|
||||
case 'ConsolidationCompleted': {
|
||||
const nodes = Number(d.nodes_processed ?? 0);
|
||||
const decay = Number(d.decay_applied ?? 0);
|
||||
const embeds = Number(d.embeddings_generated ?? 0);
|
||||
const ms = Number(d.duration_ms ?? 0);
|
||||
const tail: string[] = [];
|
||||
if (decay > 0) tail.push(`${decay} decayed`);
|
||||
if (embeds > 0) tail.push(`${embeds} embedded`);
|
||||
return {
|
||||
type: event.type,
|
||||
title: 'Consolidation swept',
|
||||
body: `${nodes} node${nodes === 1 ? '' : 's'}${tail.length ? ' · ' + tail.join(' · ') : ''} in ${(ms / 1000).toFixed(1)}s`,
|
||||
color,
|
||||
dwellMs: 6000,
|
||||
};
|
||||
}
|
||||
|
||||
case 'ConnectionDiscovered': {
|
||||
const now = Date.now();
|
||||
if (now - lastConnectionAt < CONNECTION_THROTTLE_MS) return null;
|
||||
lastConnectionAt = now;
|
||||
const kind = String(d.connection_type ?? 'link');
|
||||
const weight = Number(d.weight ?? 0);
|
||||
return {
|
||||
type: event.type,
|
||||
title: 'Bridge discovered',
|
||||
body: `${kind} · weight ${weight.toFixed(2)}`,
|
||||
color,
|
||||
dwellMs: 4500,
|
||||
};
|
||||
}
|
||||
|
||||
case 'MemoryPromoted': {
|
||||
const r = Number(d.new_retention ?? 0);
|
||||
return {
|
||||
type: event.type,
|
||||
title: 'Memory promoted',
|
||||
body: `retention ${(r * 100).toFixed(0)}%`,
|
||||
color,
|
||||
dwellMs: 4500,
|
||||
};
|
||||
}
|
||||
|
||||
case 'MemoryDemoted': {
|
||||
const r = Number(d.new_retention ?? 0);
|
||||
return {
|
||||
type: event.type,
|
||||
title: 'Memory demoted',
|
||||
body: `retention ${(r * 100).toFixed(0)}%`,
|
||||
color,
|
||||
dwellMs: 4500,
|
||||
};
|
||||
}
|
||||
|
||||
case 'MemorySuppressed': {
|
||||
const count = Number(d.suppression_count ?? 0);
|
||||
const cascade = Number(d.estimated_cascade ?? 0);
|
||||
return {
|
||||
type: event.type,
|
||||
title: 'Forgetting',
|
||||
body: cascade > 0
|
||||
? `suppression #${count} · Rac1 cascade ~${cascade} neighbors`
|
||||
: `suppression #${count}`,
|
||||
color,
|
||||
dwellMs: 5500,
|
||||
};
|
||||
}
|
||||
|
||||
case 'MemoryUnsuppressed': {
|
||||
const remaining = Number(d.remaining_count ?? 0);
|
||||
return {
|
||||
type: event.type,
|
||||
title: 'Recovered',
|
||||
body: remaining > 0 ? `${remaining} suppression${remaining === 1 ? '' : 's'} remain` : 'fully unsuppressed',
|
||||
color,
|
||||
dwellMs: 5000,
|
||||
};
|
||||
}
|
||||
|
||||
case 'Rac1CascadeSwept': {
|
||||
const seeds = Number(d.seeds ?? 0);
|
||||
const neighbors = Number(d.neighbors_affected ?? 0);
|
||||
return {
|
||||
type: event.type,
|
||||
title: 'Rac1 cascade',
|
||||
body: `${seeds} seed${seeds === 1 ? '' : 's'} · ${neighbors} dendritic spine${neighbors === 1 ? '' : 's'} pruned`,
|
||||
color,
|
||||
dwellMs: 6000,
|
||||
};
|
||||
}
|
||||
|
||||
case 'MemoryDeleted': {
|
||||
return {
|
||||
type: event.type,
|
||||
title: 'Memory deleted',
|
||||
body: String(d.id ?? '').slice(0, 8),
|
||||
color,
|
||||
dwellMs: 4000,
|
||||
};
|
||||
}
|
||||
|
||||
// Noise — never toast
|
||||
case 'Heartbeat':
|
||||
case 'SearchPerformed':
|
||||
case 'RetentionDecayed':
|
||||
case 'ActivationSpread':
|
||||
case 'ImportanceScored':
|
||||
case 'MemoryCreated':
|
||||
case 'MemoryUpdated':
|
||||
case 'DreamStarted':
|
||||
case 'DreamProgress':
|
||||
case 'ConsolidationStarted':
|
||||
case 'Connected':
|
||||
return null;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Track the latest processed event by object identity. The websocket
|
||||
// store prepends new events (index 0) and caps the array, so we walk
|
||||
// from the head until we hit a previously-seen event rather than
|
||||
// comparing only events[0] — which would drop mid-burst events when
|
||||
// multiple messages arrive in the same Svelte update tick (e.g. a
|
||||
// swarm epiphany firing DreamCompleted + ConnectionDiscovered within
|
||||
// a single millisecond).
|
||||
let lastSeen: VestigeEvent | null = null;
|
||||
|
||||
eventFeed.subscribe(events => {
|
||||
if (events.length === 0) return;
|
||||
const fresh: VestigeEvent[] = [];
|
||||
for (const e of events) {
|
||||
if (e === lastSeen) break;
|
||||
fresh.push(e);
|
||||
}
|
||||
if (fresh.length === 0) return;
|
||||
lastSeen = events[0];
|
||||
// Process oldest-first so narrative ordering is preserved.
|
||||
for (let i = fresh.length - 1; i >= 0; i--) {
|
||||
const translated = translate(fresh[i]);
|
||||
if (translated) push(translated);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
dismiss,
|
||||
clear,
|
||||
pauseDwell,
|
||||
resumeDwell,
|
||||
/** Manually fire a toast (test mode / demo button). */
|
||||
push,
|
||||
};
|
||||
}
|
||||
|
||||
export const toasts = createToastStore();
|
||||
|
||||
/** Fire a synthetic event sequence — used by the demo button in settings. */
|
||||
export function fireDemoSequence(): void {
|
||||
const demos: Omit<Toast, 'id' | 'createdAt'>[] = [
|
||||
{
|
||||
type: 'DreamCompleted',
|
||||
title: 'Dream consolidated',
|
||||
body: 'Replayed 127 memories · 43 new connections · 5 insights in 2.4s',
|
||||
color: EVENT_TYPE_COLORS.DreamCompleted,
|
||||
dwellMs: 7000,
|
||||
},
|
||||
{
|
||||
type: 'ConnectionDiscovered',
|
||||
title: 'Bridge discovered',
|
||||
body: 'semantic · weight 0.87',
|
||||
color: EVENT_TYPE_COLORS.ConnectionDiscovered,
|
||||
dwellMs: 4500,
|
||||
},
|
||||
{
|
||||
type: 'MemorySuppressed',
|
||||
title: 'Forgetting',
|
||||
body: 'suppression #2 · Rac1 cascade ~8 neighbors',
|
||||
color: EVENT_TYPE_COLORS.MemorySuppressed,
|
||||
dwellMs: 5500,
|
||||
},
|
||||
{
|
||||
type: 'ConsolidationCompleted',
|
||||
title: 'Consolidation swept',
|
||||
body: '892 nodes · 156 decayed · 48 embedded in 1.1s',
|
||||
color: EVENT_TYPE_COLORS.ConsolidationCompleted,
|
||||
dwellMs: 6000,
|
||||
},
|
||||
];
|
||||
demos.forEach((t, i) => {
|
||||
setTimeout(() => {
|
||||
// access the store getter for type safety — push is on the factory
|
||||
(toasts as unknown as { push: typeof toasts.push }).push(t);
|
||||
}, i * 800);
|
||||
});
|
||||
// satisfy unused-import lint
|
||||
void get(toasts);
|
||||
}
|
||||
|
|
@ -81,11 +81,26 @@ function createWebSocketStore() {
|
|||
update(s => ({ ...s, events: [] }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject a synthetic event into the feed as if it had arrived over the
|
||||
* WebSocket. Used by the dev-mode "Preview Birth Ritual" button on the
|
||||
* Settings page to let Sam trigger a demo of the v2.3 Memory Birth
|
||||
* Ritual without ingesting a real memory. Downstream consumers —
|
||||
* InsightToast, Graph3D — cannot distinguish synthetic from real.
|
||||
*/
|
||||
function injectEvent(event: VestigeEvent) {
|
||||
update(s => {
|
||||
const events = [event, ...s.events].slice(0, MAX_EVENTS);
|
||||
return { ...s, events };
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
connect,
|
||||
disconnect,
|
||||
clearEvents
|
||||
clearEvents,
|
||||
injectEvent
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -166,6 +166,7 @@ export type VestigeEventType =
|
|||
| 'ConnectionDiscovered'
|
||||
| 'ActivationSpread'
|
||||
| 'ImportanceScored'
|
||||
| 'DeepReferenceCompleted'
|
||||
| 'Heartbeat';
|
||||
|
||||
export interface VestigeEvent {
|
||||
|
|
@ -236,6 +237,7 @@ export const EVENT_TYPE_COLORS: Record<string, string> = {
|
|||
MemoryUnsuppressed: '#14E8C6',
|
||||
Rac1CascadeSwept: '#6E3FFF',
|
||||
SearchPerformed: '#818CF8',
|
||||
DeepReferenceCompleted: '#C4B5FD',
|
||||
DreamStarted: '#9D00FF',
|
||||
DreamProgress: '#B44AFF',
|
||||
DreamCompleted: '#C084FC',
|
||||
|
|
|
|||
300
apps/dashboard/src/routes/(app)/activation/+page.svelte
Normal file
300
apps/dashboard/src/routes/(app)/activation/+page.svelte
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* Spreading Activation Live View.
|
||||
*
|
||||
* Two sources of bursts feed the ActivationNetwork canvas:
|
||||
* 1. User search — type a query, we pick the top-1 match and fetch its
|
||||
* associations (up to 15), then pass `{source, neighbours}` as props.
|
||||
* 2. Live mode — subscribe to `$eventFeed` and, on every NEW
|
||||
* `ActivationSpread` event, trigger an overlay burst at a randomised
|
||||
* offset. Old events (those present before mount, or already
|
||||
* processed) never re-fire; we track `lastSeen` by object identity
|
||||
* so overlapping batches inside the same Svelte update tick are
|
||||
* still handled.
|
||||
*
|
||||
* All heavy lifting (decay, geometry, color, event filter) lives in
|
||||
* `$components/activation-helpers` so it's unit-tested in Node without
|
||||
* a browser.
|
||||
*/
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { api } from '$stores/api';
|
||||
import { eventFeed } from '$stores/websocket';
|
||||
import ActivationNetwork, {
|
||||
type ActivationNode,
|
||||
} from '$components/ActivationNetwork.svelte';
|
||||
import { filterNewSpreadEvents } from '$components/activation-helpers';
|
||||
import type { Memory, VestigeEvent } from '$types';
|
||||
|
||||
let searchQuery = $state('');
|
||||
let loading = $state(false);
|
||||
let searched = $state(false); // true after the first submitted search
|
||||
let errorMessage = $state<string | null>(null);
|
||||
|
||||
let focusedSource = $state<ActivationNode | null>(null);
|
||||
let focusedNeighbours = $state<ActivationNode[]>([]);
|
||||
|
||||
let liveEnabled = $state(true);
|
||||
let liveBurstKey = $state(0);
|
||||
let liveBurst = $state<{
|
||||
source: ActivationNode;
|
||||
neighbours: ActivationNode[];
|
||||
} | null>(null);
|
||||
let liveBurstsFired = $state(0);
|
||||
|
||||
// Track every memory we've seen so live-mode events (which carry only
|
||||
// IDs) can be rendered with real labels + node types. If a spread event
|
||||
// references an unknown ID we fall back to a short hash so the burst
|
||||
// still renders — this mirrors how the 3D graph degrades gracefully.
|
||||
const memoryCache = new Map<string, Memory>();
|
||||
|
||||
function rememberMemory(m: Memory) {
|
||||
memoryCache.set(m.id, m);
|
||||
}
|
||||
|
||||
function memoryToNode(m: Memory): ActivationNode {
|
||||
return {
|
||||
id: m.id,
|
||||
label: labelFor(m.content, m.id),
|
||||
nodeType: m.nodeType,
|
||||
};
|
||||
}
|
||||
|
||||
function labelFor(content: string | undefined, id: string): string {
|
||||
if (content && content.trim().length > 0) {
|
||||
const trimmed = content.trim();
|
||||
return trimmed.length > 60 ? trimmed.slice(0, 60) + '…' : trimmed;
|
||||
}
|
||||
return id.slice(0, 8);
|
||||
}
|
||||
|
||||
function fallbackNode(id: string): ActivationNode {
|
||||
const cached = memoryCache.get(id);
|
||||
if (cached) return memoryToNode(cached);
|
||||
return { id, label: id.slice(0, 8), nodeType: 'note' };
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// User-driven search → focused burst
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
async function runSearch() {
|
||||
const q = searchQuery.trim();
|
||||
if (!q) {
|
||||
// Empty query is a no-op — don't clobber the current burst.
|
||||
errorMessage = null;
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
searched = true;
|
||||
errorMessage = null;
|
||||
focusedSource = null;
|
||||
focusedNeighbours = [];
|
||||
|
||||
try {
|
||||
const searchRes = await api.search(q, 1);
|
||||
if (!searchRes.results || searchRes.results.length === 0) {
|
||||
// Leave `searched=true` + `focusedSource=null` → UI shows
|
||||
// the "no matches" empty state rather than crashing on
|
||||
// `searchRes.results[0]`.
|
||||
return;
|
||||
}
|
||||
const top = searchRes.results[0];
|
||||
rememberMemory(top);
|
||||
focusedSource = memoryToNode(top);
|
||||
|
||||
const assocRes = (await api.explore(top.id, 'associations', undefined, 15)) as
|
||||
| {
|
||||
results?: Memory[];
|
||||
nodes?: Memory[];
|
||||
// The backend has shipped at least two shapes; accept both.
|
||||
associations?: Memory[];
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
const rawList =
|
||||
assocRes?.results ?? assocRes?.nodes ?? assocRes?.associations ?? [];
|
||||
|
||||
const neighbours: ActivationNode[] = [];
|
||||
for (const n of rawList) {
|
||||
if (!n || typeof n !== 'object' || !('id' in n)) continue;
|
||||
const mem = n as Memory;
|
||||
rememberMemory(mem);
|
||||
neighbours.push(memoryToNode(mem));
|
||||
}
|
||||
focusedNeighbours = neighbours;
|
||||
} catch (e) {
|
||||
errorMessage = e instanceof Error ? e.message : String(e);
|
||||
focusedSource = null;
|
||||
focusedNeighbours = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Live mode — $eventFeed → overlay bursts
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
let feedUnsub: (() => void) | null = null;
|
||||
// Object identity of the most recently processed event. We walk the
|
||||
// feed head until we hit this reference, so mid-burst batches in one
|
||||
// Svelte tick are all processed. Mirrors toast.ts.
|
||||
let lastSeenEvent: VestigeEvent | null = null;
|
||||
let primedLiveBaseline = false;
|
||||
|
||||
onMount(() => {
|
||||
feedUnsub = eventFeed.subscribe((events) => {
|
||||
if (!events || events.length === 0) return;
|
||||
// Prime lastSeen to the current head BEFORE we're live — we don't
|
||||
// want to flood the canvas with every ActivationSpread in the
|
||||
// 200-event ring buffer on first mount. Post-prime, only new
|
||||
// events fire bursts.
|
||||
if (!primedLiveBaseline) {
|
||||
lastSeenEvent = events[0];
|
||||
primedLiveBaseline = true;
|
||||
return;
|
||||
}
|
||||
if (!liveEnabled) {
|
||||
// Still advance the baseline so toggling live back on doesn't
|
||||
// dump a backlog.
|
||||
lastSeenEvent = events[0];
|
||||
return;
|
||||
}
|
||||
const spreads = filterNewSpreadEvents(events, lastSeenEvent);
|
||||
lastSeenEvent = events[0];
|
||||
if (spreads.length === 0) return;
|
||||
for (const s of spreads) {
|
||||
const srcNode = fallbackNode(s.source_id);
|
||||
const nbrs = s.target_ids.map((tid) => fallbackNode(tid));
|
||||
liveBurstKey += 1;
|
||||
liveBurst = { source: srcNode, neighbours: nbrs };
|
||||
liveBurstsFired += 1;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (feedUnsub) feedUnsub();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="p-6 max-w-6xl mx-auto space-y-6">
|
||||
<header class="space-y-2">
|
||||
<h1 class="text-xl text-bright font-semibold">Spreading Activation</h1>
|
||||
<p class="text-xs text-muted">
|
||||
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.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="space-y-3">
|
||||
<span class="text-xs text-dim font-medium">Seed Memory</span>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search for a memory to activate..."
|
||||
bind:value={searchQuery}
|
||||
onkeydown={(e) => e.key === 'Enter' && runSearch()}
|
||||
class="flex-1 px-4 py-2.5 bg-white/[0.03] border border-synapse/10 rounded-xl text-text text-sm
|
||||
placeholder:text-muted focus:outline-none focus:border-synapse/40 transition backdrop-blur-sm"
|
||||
/>
|
||||
<button
|
||||
onclick={runSearch}
|
||||
disabled={loading}
|
||||
class="px-4 py-2.5 bg-synapse/20 border border-synapse/40 text-synapse-glow text-sm rounded-xl hover:bg-synapse/30 transition disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Activating…' : 'Activate'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Live toggle + stats -->
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<label class="flex items-center gap-2 text-dim cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={liveEnabled}
|
||||
class="accent-synapse-glow"
|
||||
/>
|
||||
<span>Live mode — overlay bursts from cognitive engine events</span>
|
||||
</label>
|
||||
<span class="text-muted">
|
||||
Live bursts fired: <span class="text-synapse-glow">{liveBurstsFired}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Canvas + empty/error states -->
|
||||
<div
|
||||
class="glass rounded-2xl overflow-hidden !border-synapse/15 bg-deep/40"
|
||||
style="min-height: 560px;"
|
||||
>
|
||||
{#if loading}
|
||||
<div class="flex items-center justify-center h-[560px] text-dim">
|
||||
<div class="text-center">
|
||||
<div class="text-2xl animate-pulse mb-2">◎</div>
|
||||
<p class="text-sm">Computing activation...</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else if errorMessage}
|
||||
<div class="flex items-center justify-center h-[560px] text-dim">
|
||||
<div class="text-center max-w-md px-6">
|
||||
<div class="text-3xl opacity-30 mb-3">⚠</div>
|
||||
<p class="text-sm text-bright mb-1">Activation failed</p>
|
||||
<p class="text-xs text-muted">{errorMessage}</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else if !focusedSource && searched}
|
||||
<div class="flex items-center justify-center h-[560px] text-dim">
|
||||
<div class="text-center max-w-md px-6">
|
||||
<div class="text-3xl opacity-20 mb-3">◬</div>
|
||||
<p class="text-sm text-bright mb-1">No matching memory</p>
|
||||
<p class="text-xs text-muted">
|
||||
Nothing in the graph matches
|
||||
<span class="text-text">"{searchQuery}"</span>. Try a broader
|
||||
query or switch on live mode to watch the engine fire its own
|
||||
bursts.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else if !focusedSource}
|
||||
<div class="flex items-center justify-center h-[560px] text-dim">
|
||||
<div class="text-center max-w-md px-6">
|
||||
<div class="text-3xl opacity-20 mb-3">◎</div>
|
||||
<p class="text-sm text-bright mb-1">Waiting for activation</p>
|
||||
<p class="text-xs text-muted">
|
||||
Seed a burst with the search bar above, or enable live mode to
|
||||
overlay bursts from the cognitive engine as they happen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<ActivationNetwork
|
||||
width={1040}
|
||||
height={560}
|
||||
source={focusedSource}
|
||||
neighbours={focusedNeighbours}
|
||||
{liveBurstKey}
|
||||
{liveBurst}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Focused burst metadata -->
|
||||
{#if focusedSource}
|
||||
<div class="p-3 glass rounded-xl !border-synapse/20">
|
||||
<div class="text-[10px] text-synapse-glow mb-1 uppercase tracking-wider">
|
||||
Seed
|
||||
</div>
|
||||
<p class="text-sm text-text">{focusedSource.label}</p>
|
||||
<div class="flex gap-2 mt-1.5 text-[10px] text-muted">
|
||||
<span>{focusedSource.nodeType}</span>
|
||||
<span>{focusedNeighbours.length} neighbours</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
532
apps/dashboard/src/routes/(app)/contradictions/+page.svelte
Normal file
532
apps/dashboard/src/routes/(app)/contradictions/+page.svelte
Normal file
|
|
@ -0,0 +1,532 @@
|
|||
<script lang="ts">
|
||||
import ContradictionArcs, { type Contradiction } from '$components/ContradictionArcs.svelte';
|
||||
import {
|
||||
severityColor,
|
||||
severityLabel,
|
||||
truncate,
|
||||
uniqueMemoryCount,
|
||||
avgTrustDelta as avgTrustDeltaFn,
|
||||
} from '$components/contradiction-helpers';
|
||||
|
||||
// TODO: swap for /api/contradictions when backend ships.
|
||||
// Expected shape matches the `Contradiction` interface in
|
||||
// $components/ContradictionArcs.svelte. Backend should derive pairs from the
|
||||
// contradiction-analysis step of deep_reference (only flag when BOTH memories
|
||||
// have >0.3 FSRS trust).
|
||||
const MOCK_CONTRADICTIONS: Contradiction[] = [
|
||||
{
|
||||
memory_a_id: 'a1',
|
||||
memory_b_id: 'b1',
|
||||
memory_a_preview: 'Dev server runs on port 3000 (default Vite config)',
|
||||
memory_b_preview: 'Dev server moved to port 3002 to avoid conflict',
|
||||
memory_a_type: 'fact',
|
||||
memory_b_type: 'decision',
|
||||
memory_a_created: '2026-01-14',
|
||||
memory_b_created: '2026-03-22',
|
||||
memory_a_tags: ['dev', 'vite'],
|
||||
memory_b_tags: ['dev', 'vite', 'decision'],
|
||||
trust_a: 0.42,
|
||||
trust_b: 0.91,
|
||||
similarity: 0.88,
|
||||
date_diff_days: 67,
|
||||
topic: 'dev server port'
|
||||
},
|
||||
{
|
||||
memory_a_id: 'a2',
|
||||
memory_b_id: 'b2',
|
||||
memory_a_preview: 'Prompt diversity helps at T>=0.6 per GPT-OSS paper',
|
||||
memory_b_preview: 'Prompt diversity monotonically HURTS at T>=0.6 (arxiv 2603.27844)',
|
||||
memory_a_type: 'concept',
|
||||
memory_b_type: 'fact',
|
||||
memory_a_created: '2026-03-30',
|
||||
memory_b_created: '2026-04-03',
|
||||
memory_a_tags: ['aimo3', 'prompting'],
|
||||
memory_b_tags: ['aimo3', 'prompting', 'evidence'],
|
||||
trust_a: 0.35,
|
||||
trust_b: 0.88,
|
||||
similarity: 0.92,
|
||||
date_diff_days: 4,
|
||||
topic: 'prompt diversity'
|
||||
},
|
||||
{
|
||||
memory_a_id: 'a3',
|
||||
memory_b_id: 'b3',
|
||||
memory_a_preview: 'Use min_p=0.05 for GPT-OSS-120B sampling',
|
||||
memory_b_preview: 'min_p scheduling fails at competition temperatures',
|
||||
memory_a_type: 'pattern',
|
||||
memory_b_type: 'fact',
|
||||
memory_a_created: '2026-04-01',
|
||||
memory_b_created: '2026-04-05',
|
||||
memory_a_tags: ['aimo3', 'sampling'],
|
||||
memory_b_tags: ['aimo3', 'sampling'],
|
||||
trust_a: 0.58,
|
||||
trust_b: 0.74,
|
||||
similarity: 0.81,
|
||||
date_diff_days: 4,
|
||||
topic: 'min_p sampling'
|
||||
},
|
||||
{
|
||||
memory_a_id: 'a4',
|
||||
memory_b_id: 'b4',
|
||||
memory_a_preview: 'LoRA rank 16 is enough for domain adaptation',
|
||||
memory_b_preview: 'LoRA rank 32 consistently outperforms rank 16 on math',
|
||||
memory_a_type: 'concept',
|
||||
memory_b_type: 'fact',
|
||||
memory_a_created: '2026-02-10',
|
||||
memory_b_created: '2026-04-12',
|
||||
memory_a_tags: ['lora', 'training'],
|
||||
memory_b_tags: ['lora', 'training', 'nemotron'],
|
||||
trust_a: 0.48,
|
||||
trust_b: 0.76,
|
||||
similarity: 0.74,
|
||||
date_diff_days: 61,
|
||||
topic: 'LoRA rank'
|
||||
},
|
||||
{
|
||||
memory_a_id: 'a5',
|
||||
memory_b_id: 'b5',
|
||||
memory_a_preview: 'Sam prefers Rust for all backend services',
|
||||
memory_b_preview: 'Sam chose Axum + Rust for Nullgaze backend',
|
||||
memory_a_type: 'note',
|
||||
memory_b_type: 'decision',
|
||||
memory_a_created: '2026-01-05',
|
||||
memory_b_created: '2026-02-18',
|
||||
memory_a_tags: ['preference', 'sam'],
|
||||
memory_b_tags: ['nullgaze', 'backend'],
|
||||
trust_a: 0.81,
|
||||
trust_b: 0.88,
|
||||
similarity: 0.42,
|
||||
date_diff_days: 44,
|
||||
topic: 'backend language'
|
||||
},
|
||||
{
|
||||
memory_a_id: 'a6',
|
||||
memory_b_id: 'b6',
|
||||
memory_a_preview: 'Warm-start from checkpoint saves 8h of training',
|
||||
memory_b_preview: 'Warm-start code never loaded the PEFT adapter correctly',
|
||||
memory_a_type: 'pattern',
|
||||
memory_b_type: 'fact',
|
||||
memory_a_created: '2026-03-11',
|
||||
memory_b_created: '2026-04-16',
|
||||
memory_a_tags: ['training', 'warm-start'],
|
||||
memory_b_tags: ['training', 'warm-start', 'bug-fix'],
|
||||
trust_a: 0.55,
|
||||
trust_b: 0.93,
|
||||
similarity: 0.79,
|
||||
date_diff_days: 36,
|
||||
topic: 'warm-start correctness'
|
||||
},
|
||||
{
|
||||
memory_a_id: 'a7',
|
||||
memory_b_id: 'b7',
|
||||
memory_a_preview: 'Three.js force-directed graph runs fine at 5k nodes',
|
||||
memory_b_preview: 'WebGL graph stutters above 2k nodes on M1 MacBook Air',
|
||||
memory_a_type: 'fact',
|
||||
memory_b_type: 'fact',
|
||||
memory_a_created: '2025-12-02',
|
||||
memory_b_created: '2026-03-29',
|
||||
memory_a_tags: ['vestige', 'graph', 'perf'],
|
||||
memory_b_tags: ['vestige', 'graph', 'perf'],
|
||||
trust_a: 0.39,
|
||||
trust_b: 0.72,
|
||||
similarity: 0.67,
|
||||
date_diff_days: 117,
|
||||
topic: 'graph performance'
|
||||
},
|
||||
{
|
||||
memory_a_id: 'a8',
|
||||
memory_b_id: 'b8',
|
||||
memory_a_preview: 'Submit GPT-OSS with 16384 token budget for AIMO',
|
||||
memory_b_preview: 'AIMO3 baseline at 32768 tokens scored 44/50',
|
||||
memory_a_type: 'pattern',
|
||||
memory_b_type: 'event',
|
||||
memory_a_created: '2026-04-04',
|
||||
memory_b_created: '2026-04-10',
|
||||
memory_a_tags: ['aimo3', 'tokens'],
|
||||
memory_b_tags: ['aimo3', 'baseline'],
|
||||
trust_a: 0.31,
|
||||
trust_b: 0.85,
|
||||
similarity: 0.73,
|
||||
date_diff_days: 6,
|
||||
topic: 'token budget'
|
||||
},
|
||||
{
|
||||
memory_a_id: 'a9',
|
||||
memory_b_id: 'b9',
|
||||
memory_a_preview: 'FSRS-6 parameters require ~1k reviews to train',
|
||||
memory_b_preview: 'FSRS-6 default parameters work fine out of the box',
|
||||
memory_a_type: 'concept',
|
||||
memory_b_type: 'concept',
|
||||
memory_a_created: '2026-01-22',
|
||||
memory_b_created: '2026-02-28',
|
||||
memory_a_tags: ['fsrs', 'training'],
|
||||
memory_b_tags: ['fsrs'],
|
||||
trust_a: 0.62,
|
||||
trust_b: 0.54,
|
||||
similarity: 0.57,
|
||||
date_diff_days: 37,
|
||||
topic: 'FSRS parameter tuning'
|
||||
},
|
||||
{
|
||||
memory_a_id: 'a10',
|
||||
memory_b_id: 'b10',
|
||||
memory_a_preview: 'Tailwind 4 requires explicit CSS import only',
|
||||
memory_b_preview: 'Tailwind 4 config still supports tailwind.config.js',
|
||||
memory_a_type: 'fact',
|
||||
memory_b_type: 'fact',
|
||||
memory_a_created: '2026-01-30',
|
||||
memory_b_created: '2026-02-14',
|
||||
memory_a_tags: ['tailwind', 'config'],
|
||||
memory_b_tags: ['tailwind', 'config'],
|
||||
trust_a: 0.47,
|
||||
trust_b: 0.33,
|
||||
similarity: 0.85,
|
||||
date_diff_days: 15,
|
||||
topic: 'Tailwind 4 config'
|
||||
},
|
||||
{
|
||||
memory_a_id: 'a11',
|
||||
memory_b_id: 'b11',
|
||||
memory_a_preview: 'Kaggle API silently ignores invalid modelDataSources slugs',
|
||||
memory_b_preview: 'Kaggle API throws an error when model slug is invalid',
|
||||
memory_a_type: 'fact',
|
||||
memory_b_type: 'concept',
|
||||
memory_a_created: '2026-04-07',
|
||||
memory_b_created: '2026-02-20',
|
||||
memory_a_tags: ['kaggle', 'bug-fix', 'api'],
|
||||
memory_b_tags: ['kaggle', 'api'],
|
||||
trust_a: 0.89,
|
||||
trust_b: 0.28,
|
||||
similarity: 0.91,
|
||||
date_diff_days: 46,
|
||||
topic: 'Kaggle API validation'
|
||||
},
|
||||
{
|
||||
memory_a_id: 'a12',
|
||||
memory_b_id: 'b12',
|
||||
memory_a_preview: 'USearch HNSW is 20x faster than FAISS for embeddings',
|
||||
memory_b_preview: 'FAISS IVF is the fastest vector index at scale',
|
||||
memory_a_type: 'fact',
|
||||
memory_b_type: 'concept',
|
||||
memory_a_created: '2026-02-01',
|
||||
memory_b_created: '2025-11-15',
|
||||
memory_a_tags: ['vectors', 'perf'],
|
||||
memory_b_tags: ['vectors', 'perf'],
|
||||
trust_a: 0.78,
|
||||
trust_b: 0.36,
|
||||
similarity: 0.69,
|
||||
date_diff_days: 78,
|
||||
topic: 'vector index perf'
|
||||
},
|
||||
{
|
||||
memory_a_id: 'a13',
|
||||
memory_b_id: 'b13',
|
||||
memory_a_preview: 'Orbit Wars leaderboard scores weight by top-10 consistency',
|
||||
memory_b_preview: 'Orbit Wars uses single-best-episode scoring',
|
||||
memory_a_type: 'fact',
|
||||
memory_b_type: 'fact',
|
||||
memory_a_created: '2026-04-18',
|
||||
memory_b_created: '2026-04-10',
|
||||
memory_a_tags: ['orbit-wars', 'scoring'],
|
||||
memory_b_tags: ['orbit-wars', 'scoring'],
|
||||
trust_a: 0.64,
|
||||
trust_b: 0.52,
|
||||
similarity: 0.82,
|
||||
date_diff_days: 8,
|
||||
topic: 'Orbit Wars scoring'
|
||||
},
|
||||
{
|
||||
memory_a_id: 'a14',
|
||||
memory_b_id: 'b14',
|
||||
memory_a_preview: 'Sam commits to morning posts 8am ET',
|
||||
memory_b_preview: 'Morning cadence moved to 9am ET after energy review',
|
||||
memory_a_type: 'decision',
|
||||
memory_b_type: 'decision',
|
||||
memory_a_created: '2026-03-01',
|
||||
memory_b_created: '2026-04-15',
|
||||
memory_a_tags: ['cadence', 'content'],
|
||||
memory_b_tags: ['cadence', 'content'],
|
||||
trust_a: 0.50,
|
||||
trust_b: 0.81,
|
||||
similarity: 0.58,
|
||||
date_diff_days: 45,
|
||||
topic: 'posting cadence'
|
||||
},
|
||||
{
|
||||
memory_a_id: 'a15',
|
||||
memory_b_id: 'b15',
|
||||
memory_a_preview: 'Dream cycle consolidates ~50 memories per run',
|
||||
memory_b_preview: 'Dream cycle replays closer to 120 memories in practice',
|
||||
memory_a_type: 'fact',
|
||||
memory_b_type: 'fact',
|
||||
memory_a_created: '2026-02-15',
|
||||
memory_b_created: '2026-04-08',
|
||||
memory_a_tags: ['vestige', 'dream'],
|
||||
memory_b_tags: ['vestige', 'dream'],
|
||||
trust_a: 0.44,
|
||||
trust_b: 0.79,
|
||||
similarity: 0.76,
|
||||
date_diff_days: 52,
|
||||
topic: 'dream cycle count'
|
||||
},
|
||||
{
|
||||
memory_a_id: 'a16',
|
||||
memory_b_id: 'b16',
|
||||
memory_a_preview: 'Never commit API keys to git; use .env files',
|
||||
memory_b_preview: 'Environment secrets should live in a 1Password vault',
|
||||
memory_a_type: 'pattern',
|
||||
memory_b_type: 'pattern',
|
||||
memory_a_created: '2025-10-11',
|
||||
memory_b_created: '2026-03-20',
|
||||
memory_a_tags: ['security', 'secrets'],
|
||||
memory_b_tags: ['security', 'secrets'],
|
||||
trust_a: 0.72,
|
||||
trust_b: 0.64,
|
||||
similarity: 0.48,
|
||||
date_diff_days: 160,
|
||||
topic: 'secret storage'
|
||||
}
|
||||
];
|
||||
|
||||
// --- Filters ---
|
||||
type Filter = 'all' | 'recent' | 'high-trust' | 'topic';
|
||||
let filter = $state<Filter>('all');
|
||||
let topicFilter = $state<string>('');
|
||||
|
||||
const uniqueTopics = $derived(
|
||||
Array.from(new Set(MOCK_CONTRADICTIONS.map((c) => c.topic))).sort()
|
||||
);
|
||||
|
||||
const filtered = $derived.by<Contradiction[]>(() => {
|
||||
switch (filter) {
|
||||
case 'recent':
|
||||
// Within 7 days of "now" — use date_diff as a proxy by keeping pairs
|
||||
// where either memory was created within the last 7 days of our fixed
|
||||
// mock "today" (2026-04-20). Simple approach: keep pairs whose newest
|
||||
// created date is within 7 days of 2026-04-20.
|
||||
{
|
||||
const now = new Date('2026-04-20').getTime();
|
||||
const week = 7 * 24 * 60 * 60 * 1000;
|
||||
return MOCK_CONTRADICTIONS.filter((c) => {
|
||||
const aT = c.memory_a_created ? new Date(c.memory_a_created).getTime() : 0;
|
||||
const bT = c.memory_b_created ? new Date(c.memory_b_created).getTime() : 0;
|
||||
return now - Math.max(aT, bT) <= week;
|
||||
});
|
||||
}
|
||||
case 'high-trust':
|
||||
return MOCK_CONTRADICTIONS.filter(
|
||||
(c) => Math.min(c.trust_a, c.trust_b) > 0.6
|
||||
);
|
||||
case 'topic':
|
||||
return topicFilter
|
||||
? MOCK_CONTRADICTIONS.filter((c) => c.topic === topicFilter)
|
||||
: MOCK_CONTRADICTIONS;
|
||||
case 'all':
|
||||
default:
|
||||
return MOCK_CONTRADICTIONS;
|
||||
}
|
||||
});
|
||||
|
||||
// --- Selection / focused pair ---
|
||||
let focusedPairIndex = $state<number | null>(null);
|
||||
|
||||
function selectPair(i: number | null) {
|
||||
focusedPairIndex = i;
|
||||
}
|
||||
|
||||
// --- Stats. `TOTAL_CONTRADICTIONS_DETECTED` stays illustrative so the tile
|
||||
// reads like a system-wide count once the backend ships; everything else
|
||||
// is derived from the pairs the page actually holds so the numbers are
|
||||
// self-consistent with what the user sees. ---
|
||||
const TOTAL_CONTRADICTIONS_DETECTED = 47;
|
||||
const totalMemoriesInvolved = $derived(uniqueMemoryCount(MOCK_CONTRADICTIONS));
|
||||
const avgTrustDelta = $derived(avgTrustDeltaFn(MOCK_CONTRADICTIONS));
|
||||
|
||||
// Map filtered index -> original index in MOCK_CONTRADICTIONS so the
|
||||
// constellation and sidebar stay in sync regardless of which filter is on.
|
||||
const visibleList = $derived.by<{ orig: number; c: Contradiction }[]>(() => {
|
||||
const byId = new Map(MOCK_CONTRADICTIONS.map((c, i) => [c.memory_a_id + '|' + c.memory_b_id, i]));
|
||||
return filtered.map((c) => ({
|
||||
orig: byId.get(c.memory_a_id + '|' + c.memory_b_id) ?? 0,
|
||||
c
|
||||
}));
|
||||
});
|
||||
|
||||
// The ContradictionArcs component receives the filtered list; its internal
|
||||
// indices run 0..filtered.length-1. We translate when the sidebar clicks.
|
||||
function sidebarClick(localIndex: number) {
|
||||
focusedPairIndex = focusedPairIndex === localIndex ? null : localIndex;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="min-h-full p-6 space-y-6">
|
||||
<!-- Header -->
|
||||
<header class="space-y-1">
|
||||
<h1 class="text-2xl text-bright font-semibold tracking-tight">
|
||||
Contradiction Constellation
|
||||
</h1>
|
||||
<p class="text-sm text-dim">Where your memory disagrees with itself</p>
|
||||
</header>
|
||||
|
||||
<!-- Stats bar -->
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<div class="p-4 glass rounded-xl">
|
||||
<div class="text-2xl text-bright font-bold">{TOTAL_CONTRADICTIONS_DETECTED}</div>
|
||||
<div class="text-xs text-dim mt-1">
|
||||
contradictions across {totalMemoriesInvolved.toLocaleString()} memories
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4 glass rounded-xl">
|
||||
<div class="text-2xl font-bold" style="color: #f59e0b">
|
||||
{avgTrustDelta.toFixed(2)}
|
||||
</div>
|
||||
<div class="text-xs text-dim mt-1">average trust delta</div>
|
||||
</div>
|
||||
<div class="p-4 glass rounded-xl">
|
||||
<div class="text-2xl text-bright font-bold">{filtered.length}</div>
|
||||
<div class="text-xs text-dim mt-1">visible in current filter</div>
|
||||
</div>
|
||||
<div class="p-4 glass rounded-xl">
|
||||
<div class="text-2xl font-bold" style="color: #ef4444">
|
||||
{filtered.filter((c) => c.similarity > 0.7).length}
|
||||
</div>
|
||||
<div class="text-xs text-dim mt-1">strong conflicts</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter bar -->
|
||||
<div class="flex flex-wrap gap-2 items-center">
|
||||
{#each [{ id: 'all', label: 'All' }, { id: 'recent', label: 'Recent (7d)' }, { id: 'high-trust', label: 'High trust (>60%)' }, { id: 'topic', label: 'By topic' }] as f (f.id)}
|
||||
<button
|
||||
onclick={() => {
|
||||
filter = f.id as Filter;
|
||||
focusedPairIndex = null;
|
||||
}}
|
||||
class="px-3 py-1.5 rounded-lg text-xs border transition
|
||||
{filter === f.id
|
||||
? 'bg-synapse/15 border-synapse/40 text-synapse-glow'
|
||||
: 'border-subtle/30 text-dim hover:text-text hover:bg-white/[0.03]'}"
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
{/each}
|
||||
{#if filter === 'topic'}
|
||||
<select
|
||||
bind:value={topicFilter}
|
||||
class="ml-2 px-3 py-1.5 rounded-lg text-xs glass-subtle border border-subtle/30 text-text"
|
||||
>
|
||||
<option value="">All topics</option>
|
||||
{#each uniqueTopics as t}
|
||||
<option value={t}>{t}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
{#if focusedPairIndex !== null}
|
||||
<button
|
||||
onclick={() => (focusedPairIndex = null)}
|
||||
class="ml-auto px-3 py-1.5 rounded-lg text-xs border border-subtle/30 text-dim hover:text-text"
|
||||
>
|
||||
Clear focus
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Main view: constellation + sidebar -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-[1fr_340px] gap-4">
|
||||
<!-- Constellation -->
|
||||
<div class="glass-panel rounded-2xl p-3 min-h-[520px] relative">
|
||||
{#if filtered.length === 0}
|
||||
<div class="flex items-center justify-center h-full text-dim text-sm">
|
||||
No contradictions match this filter.
|
||||
</div>
|
||||
{:else}
|
||||
<ContradictionArcs
|
||||
contradictions={filtered}
|
||||
{focusedPairIndex}
|
||||
onSelectPair={selectPair}
|
||||
width={800}
|
||||
height={600}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Sidebar: pair list -->
|
||||
<aside class="glass rounded-2xl p-3 space-y-2 max-h-[620px] overflow-y-auto">
|
||||
<div class="flex items-center justify-between px-1 pb-2 sticky top-0 bg-deep/60 backdrop-blur-sm z-10">
|
||||
<span class="text-xs text-dim uppercase tracking-wider">Pairs</span>
|
||||
<span class="text-xs text-muted">{visibleList.length}</span>
|
||||
</div>
|
||||
|
||||
{#if visibleList.length === 0}
|
||||
<div class="text-xs text-muted p-3">No pairs visible.</div>
|
||||
{/if}
|
||||
|
||||
{#each visibleList as entry, localIndex (entry.c.memory_a_id + '|' + entry.c.memory_b_id)}
|
||||
{@const c = entry.c}
|
||||
{@const isFocused = focusedPairIndex === localIndex}
|
||||
<button
|
||||
onclick={() => sidebarClick(localIndex)}
|
||||
class="w-full text-left p-3 rounded-xl border transition
|
||||
{isFocused
|
||||
? 'bg-synapse/10 border-synapse/40 shadow-[0_0_12px_rgba(99,102,241,0.18)]'
|
||||
: 'border-subtle/20 hover:border-synapse/30 hover:bg-white/[0.02]'}"
|
||||
>
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<div
|
||||
class="w-2 h-2 rounded-full"
|
||||
style="background: {severityColor(c.similarity)}"
|
||||
></div>
|
||||
<span class="text-[10px] uppercase tracking-wider" style="color: {severityColor(c.similarity)}">
|
||||
{severityLabel(c.similarity)}
|
||||
</span>
|
||||
<span class="text-[10px] text-muted ml-auto">
|
||||
{(c.similarity * 100).toFixed(0)}% sim · {c.date_diff_days}d
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs text-text font-medium mb-1 truncate">
|
||||
{c.topic}
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-start gap-2 text-[11px]">
|
||||
<span class="text-muted mt-0.5 shrink-0">A</span>
|
||||
<span class="text-dim">{truncate(c.memory_a_preview)}</span>
|
||||
<span class="ml-auto text-[10px] text-muted shrink-0">
|
||||
{(c.trust_a * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-start gap-2 text-[11px]">
|
||||
<span class="text-muted mt-0.5 shrink-0">B</span>
|
||||
<span class="text-dim">{truncate(c.memory_b_preview)}</span>
|
||||
<span class="ml-auto text-[10px] text-muted shrink-0">
|
||||
{(c.trust_b * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if isFocused}
|
||||
<div class="mt-3 pt-3 border-t border-subtle/20 space-y-2">
|
||||
<div class="text-[10px] text-muted uppercase tracking-wider">Full memory A</div>
|
||||
<div class="text-[11px] text-text">{c.memory_a_preview}</div>
|
||||
{#if c.memory_a_tags && c.memory_a_tags.length > 0}
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each c.memory_a_tags as t}
|
||||
<span class="text-[9px] px-1.5 py-0.5 rounded bg-white/[0.04] text-muted">{t}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="text-[10px] text-muted uppercase tracking-wider pt-1">Full memory B</div>
|
||||
<div class="text-[11px] text-text">{c.memory_b_preview}</div>
|
||||
{#if c.memory_b_tags && c.memory_b_tags.length > 0}
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each c.memory_b_tags as t}
|
||||
<span class="text-[9px] px-1.5 py-0.5 rounded bg-white/[0.04] text-muted">{t}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
489
apps/dashboard/src/routes/(app)/dreams/+page.svelte
Normal file
489
apps/dashboard/src/routes/(app)/dreams/+page.svelte
Normal file
|
|
@ -0,0 +1,489 @@
|
|||
<!--
|
||||
Dream Cinema — scrubbable replay of Vestige's 5-stage dream consolidation.
|
||||
|
||||
The /api/dream endpoint returns a DreamResult. We render the 5 phases of
|
||||
the MemoryDreamer pipeline (Replay → Cross-reference → Strengthen → Prune
|
||||
→ Transfer) and a sorted insight list. Clicking "Dream Now" triggers a
|
||||
fresh dream; the scrubber then lets the user step through the stages.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { api } from '$stores/api';
|
||||
import type { DreamResult } from '$types';
|
||||
import DreamStageReplay from '$components/DreamStageReplay.svelte';
|
||||
import DreamInsightCard from '$components/DreamInsightCard.svelte';
|
||||
import {
|
||||
STAGE_NAMES,
|
||||
clampStage,
|
||||
formatDurationMs,
|
||||
} from '$components/dream-helpers';
|
||||
|
||||
let dreamResult: DreamResult | null = $state(null);
|
||||
let stage = $state(1);
|
||||
let dreaming = $state(false);
|
||||
let error: string | null = $state(null);
|
||||
|
||||
let hasDream = $derived(dreamResult !== null);
|
||||
|
||||
let sortedInsights = $derived.by(() => {
|
||||
const r = dreamResult;
|
||||
if (!r) return [];
|
||||
return [...r.insights].sort((a, b) => (b.noveltyScore ?? 0) - (a.noveltyScore ?? 0));
|
||||
});
|
||||
|
||||
async function runDream() {
|
||||
if (dreaming) return;
|
||||
dreaming = true;
|
||||
error = null;
|
||||
try {
|
||||
const result = await api.dream();
|
||||
dreamResult = result;
|
||||
stage = 1;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Dream failed';
|
||||
} finally {
|
||||
dreaming = false;
|
||||
}
|
||||
}
|
||||
|
||||
function setStage(n: number) {
|
||||
stage = clampStage(n);
|
||||
}
|
||||
|
||||
function onScrub(e: Event) {
|
||||
const v = Number((e.currentTarget as HTMLInputElement).value);
|
||||
setStage(v);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Dream Cinema · Vestige</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="p-6 max-w-7xl mx-auto space-y-6">
|
||||
<!-- Header -->
|
||||
<header class="flex items-start justify-between flex-wrap gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl text-bright font-semibold tracking-tight flex items-center gap-3">
|
||||
<span class="header-glyph">✦</span>
|
||||
Dream Cinema
|
||||
</h1>
|
||||
<p class="text-sm text-dim mt-1 max-w-xl leading-snug">
|
||||
Scrub through Vestige's 5-stage consolidation cycle. Replay, cross-reference,
|
||||
strengthen, prune, transfer. Watch episodic become semantic.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={runDream}
|
||||
disabled={dreaming}
|
||||
class="dream-button"
|
||||
class:is-dreaming={dreaming}
|
||||
>
|
||||
{#if dreaming}
|
||||
<span class="spinner" aria-hidden="true"></span>
|
||||
<span>Dreaming...</span>
|
||||
{:else}
|
||||
<span class="dream-icon" aria-hidden="true">✦</span>
|
||||
<span>Dream Now</span>
|
||||
{/if}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{#if error}
|
||||
<div class="glass-subtle rounded-xl px-4 py-3 text-sm border !border-decay/40 text-decay">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !hasDream && !dreaming}
|
||||
<!-- Empty state -->
|
||||
<div class="empty-state glass-panel rounded-2xl p-12 text-center space-y-3">
|
||||
<div class="empty-glyph">✦</div>
|
||||
<p class="text-bright font-semibold">No dream yet.</p>
|
||||
<p class="text-dim text-sm">Click Dream Now to begin.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Scrubber + stage markers -->
|
||||
<section class="glass-panel rounded-2xl p-5 space-y-4">
|
||||
<div class="flex items-center justify-between gap-2 flex-wrap">
|
||||
<div class="text-[11px] text-dream-glow uppercase tracking-[0.18em] font-semibold">
|
||||
Stage {stage} · {STAGE_NAMES[stage - 1]}
|
||||
</div>
|
||||
<div class="flex gap-1 text-[11px] text-dim">
|
||||
<button
|
||||
type="button"
|
||||
class="step-btn"
|
||||
onclick={() => setStage(stage - 1)}
|
||||
disabled={stage <= 1 || dreaming}
|
||||
aria-label="Previous stage">◀</button>
|
||||
<button
|
||||
type="button"
|
||||
class="step-btn"
|
||||
onclick={() => setStage(stage + 1)}
|
||||
disabled={stage >= 5 || dreaming}
|
||||
aria-label="Next stage">▶</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scrubber -->
|
||||
<div class="scrubber-wrap">
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="5"
|
||||
step="1"
|
||||
value={stage}
|
||||
oninput={onScrub}
|
||||
disabled={dreaming}
|
||||
class="scrubber"
|
||||
aria-label="Dream stage scrubber"
|
||||
/>
|
||||
<div class="scrubber-ticks">
|
||||
{#each STAGE_NAMES as name, i (name)}
|
||||
<button
|
||||
type="button"
|
||||
class="tick"
|
||||
class:active={stage === i + 1}
|
||||
class:passed={stage > i + 1}
|
||||
onclick={() => setStage(i + 1)}
|
||||
disabled={dreaming}
|
||||
>
|
||||
<span class="tick-dot"></span>
|
||||
<span class="tick-label">{i + 1}. {name}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Main grid: stage replay + insights -->
|
||||
<section class="grid gap-6 lg:grid-cols-[1fr_360px]">
|
||||
<!-- Stage replay -->
|
||||
<DreamStageReplay {stage} {dreamResult} />
|
||||
|
||||
<!-- Insights panel -->
|
||||
<aside class="glass-panel rounded-2xl p-4 space-y-3 min-h-[240px]">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold text-bright">Insights</h2>
|
||||
<span class="text-[10px] text-dim uppercase tracking-wider">
|
||||
{sortedInsights.length} total · by novelty
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="insights-scroll space-y-3">
|
||||
{#if sortedInsights.length === 0}
|
||||
<div class="text-center py-8 text-dim text-sm">
|
||||
{#if dreaming}
|
||||
Dreaming...
|
||||
{:else}
|
||||
No insights generated this cycle.
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
{#each sortedInsights as insight, i (i + '-' + (insight.insight?.slice(0, 32) ?? ''))}
|
||||
<DreamInsightCard {insight} index={i} />
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<!-- Stats footer -->
|
||||
{#if dreamResult}
|
||||
<footer class="glass-subtle rounded-2xl p-4 grid gap-3 grid-cols-2 md:grid-cols-5">
|
||||
<div class="stat-cell">
|
||||
<div class="stat-value">{dreamResult.memoriesReplayed ?? 0}</div>
|
||||
<div class="stat-label">Replayed</div>
|
||||
</div>
|
||||
<div class="stat-cell">
|
||||
<div class="stat-value">{dreamResult.stats?.newConnectionsFound ?? 0}</div>
|
||||
<div class="stat-label">Connections Found</div>
|
||||
</div>
|
||||
<div class="stat-cell">
|
||||
<div class="stat-value">{dreamResult.connectionsPersisted ?? 0}</div>
|
||||
<div class="stat-label">Connections Persisted</div>
|
||||
</div>
|
||||
<div class="stat-cell">
|
||||
<div class="stat-value">{dreamResult.stats?.insightsGenerated ?? 0}</div>
|
||||
<div class="stat-label">Insights</div>
|
||||
</div>
|
||||
<div class="stat-cell">
|
||||
<div class="stat-value">{formatDurationMs(dreamResult.stats?.durationMs)}</div>
|
||||
<div class="stat-label">Duration</div>
|
||||
</div>
|
||||
</footer>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.header-glyph {
|
||||
display: inline-block;
|
||||
color: var(--color-dream-glow);
|
||||
text-shadow:
|
||||
0 0 12px var(--color-dream),
|
||||
0 0 24px color-mix(in srgb, var(--color-dream) 50%, transparent);
|
||||
animation: twinkle 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes twinkle {
|
||||
0%, 100% { opacity: 1; transform: rotate(0deg); }
|
||||
50% { opacity: 0.75; transform: rotate(10deg); }
|
||||
}
|
||||
|
||||
.dream-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.7rem 1.4rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: white;
|
||||
background: linear-gradient(135deg, var(--color-dream), var(--color-synapse));
|
||||
border: 1px solid color-mix(in srgb, var(--color-dream-glow) 60%, transparent);
|
||||
box-shadow:
|
||||
inset 0 1px 0 0 rgba(255, 255, 255, 0.18),
|
||||
0 8px 24px -6px rgba(168, 85, 247, 0.55),
|
||||
0 0 48px -10px rgba(168, 85, 247, 0.45);
|
||||
cursor: pointer;
|
||||
transition: transform 400ms cubic-bezier(0.34, 1.56, 0.64, 1),
|
||||
box-shadow 220ms ease, filter 220ms ease;
|
||||
}
|
||||
|
||||
.dream-button:hover:not(:disabled) {
|
||||
transform: translateY(-2px) scale(1.03);
|
||||
box-shadow:
|
||||
inset 0 1px 0 0 rgba(255, 255, 255, 0.22),
|
||||
0 12px 32px -6px rgba(168, 85, 247, 0.7),
|
||||
0 0 64px -10px rgba(168, 85, 247, 0.55);
|
||||
}
|
||||
|
||||
.dream-button:disabled {
|
||||
cursor: not-allowed;
|
||||
filter: saturate(0.85);
|
||||
}
|
||||
|
||||
.dream-button.is-dreaming {
|
||||
background: linear-gradient(135deg, var(--color-synapse), var(--color-dream));
|
||||
animation: button-breathe 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes button-breathe {
|
||||
0%, 100% { box-shadow: 0 8px 24px -6px rgba(168, 85, 247, 0.5), 0 0 48px -10px rgba(168, 85, 247, 0.4); }
|
||||
50% { box-shadow: 0 12px 36px -6px rgba(168, 85, 247, 0.8), 0 0 80px -10px rgba(168, 85, 247, 0.6); }
|
||||
}
|
||||
|
||||
.dream-icon {
|
||||
display: inline-block;
|
||||
animation: twinkle 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(255, 255, 255, 0.25);
|
||||
border-top-color: white;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
border: 1px dashed rgba(168, 85, 247, 0.25);
|
||||
}
|
||||
|
||||
.empty-glyph {
|
||||
font-size: 3rem;
|
||||
color: var(--color-dream-glow);
|
||||
opacity: 0.5;
|
||||
text-shadow: 0 0 20px var(--color-dream);
|
||||
animation: twinkle 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Scrubber */
|
||||
.scrubber-wrap {
|
||||
position: relative;
|
||||
padding: 4px 0 8px;
|
||||
}
|
||||
|
||||
.scrubber {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--color-synapse-glow) 0%,
|
||||
var(--color-dream) 50%,
|
||||
var(--color-recall) 100%
|
||||
);
|
||||
opacity: 0.35;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
transition: opacity 220ms ease;
|
||||
}
|
||||
|
||||
.scrubber:hover:not(:disabled) {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.scrubber::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-dream-glow);
|
||||
border: 2px solid white;
|
||||
box-shadow:
|
||||
0 0 0 3px rgba(192, 132, 252, 0.25),
|
||||
0 0 20px var(--color-dream),
|
||||
0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
cursor: grab;
|
||||
transition: transform 400ms cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.scrubber::-webkit-slider-thumb:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.scrubber::-moz-range-thumb {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-dream-glow);
|
||||
border: 2px solid white;
|
||||
box-shadow:
|
||||
0 0 0 3px rgba(192, 132, 252, 0.25),
|
||||
0 0 20px var(--color-dream);
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.scrubber:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
.scrubber-ticks {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 10px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.tick {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 2px 4px;
|
||||
color: var(--color-dim);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.04em;
|
||||
transition: color 220ms ease, transform 220ms cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.tick:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.tick:hover:not(:disabled) {
|
||||
color: var(--color-dream-glow);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.tick-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
transition: all 280ms ease;
|
||||
}
|
||||
|
||||
.tick.passed .tick-dot {
|
||||
background: var(--color-synapse-glow);
|
||||
border-color: var(--color-synapse-glow);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.tick.active .tick-dot {
|
||||
background: var(--color-dream-glow);
|
||||
border-color: white;
|
||||
box-shadow:
|
||||
0 0 0 3px rgba(192, 132, 252, 0.3),
|
||||
0 0 14px var(--color-dream);
|
||||
transform: scale(1.3);
|
||||
}
|
||||
|
||||
.tick.active {
|
||||
color: var(--color-dream-glow);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tick-label {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.step-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
background: rgba(99, 102, 241, 0.1);
|
||||
border: 1px solid rgba(99, 102, 241, 0.2);
|
||||
color: var(--color-synapse-glow);
|
||||
cursor: pointer;
|
||||
transition: all 180ms ease;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.step-btn:hover:not(:disabled) {
|
||||
background: rgba(99, 102, 241, 0.2);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.step-btn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Insights */
|
||||
.insights-scroll {
|
||||
max-height: 520px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
/* Stat cells */
|
||||
.stat-cell {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-left: 2px solid rgba(168, 85, 247, 0.3);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-bright);
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 10px;
|
||||
color: var(--color-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
margin-top: 2px;
|
||||
}
|
||||
</style>
|
||||
387
apps/dashboard/src/routes/(app)/duplicates/+page.svelte
Normal file
387
apps/dashboard/src/routes/(app)/duplicates/+page.svelte
Normal file
|
|
@ -0,0 +1,387 @@
|
|||
<!--
|
||||
Memory Hygiene — Duplicate Detection
|
||||
Dashboard exposure of the `find_duplicates` MCP tool. Threshold slider
|
||||
(0.70-0.95) reruns cosine-similarity clustering. Each cluster renders as a
|
||||
DuplicateCluster with similarity bar, stacked memory cards, and merge /
|
||||
review / dismiss actions.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import DuplicateCluster from '$components/DuplicateCluster.svelte';
|
||||
import { clusterKey, filterByThreshold } from '$components/duplicates-helpers';
|
||||
|
||||
interface ClusterMemory {
|
||||
id: string;
|
||||
content: string;
|
||||
nodeType: string;
|
||||
tags: string[];
|
||||
retention: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface Cluster {
|
||||
similarity: number;
|
||||
memories: ClusterMemory[];
|
||||
suggestedAction: 'merge' | 'review';
|
||||
}
|
||||
|
||||
interface DuplicatesResponse {
|
||||
clusters: Cluster[];
|
||||
}
|
||||
|
||||
let threshold = $state(0.8);
|
||||
let clusters: Cluster[] = $state([]);
|
||||
// Dismissed clusters are tracked by stable identity (sorted member ids) so
|
||||
// dismissals survive a re-fetch. If the cluster membership changes, the key
|
||||
// changes and the cluster is treated as fresh.
|
||||
let dismissed = $state(new Set<string>());
|
||||
let loading = $state(true);
|
||||
let error: string | null = $state(null);
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
// Mock realistic response. Swap for real fetch when backend ships.
|
||||
// TODO(backend-swap): replace `mockFetchDuplicates` with:
|
||||
// const res = await fetch(`/api/duplicates?threshold=${t}`);
|
||||
// return (await res.json()) as DuplicatesResponse;
|
||||
// The pure `filterByThreshold` helper in duplicates-helpers.ts mirrors the
|
||||
// server-side >= semantics so the UI behaves identically before and after.
|
||||
async function mockFetchDuplicates(t: number): Promise<DuplicatesResponse> {
|
||||
// Simulate latency so the skeleton is visible.
|
||||
await new Promise((r) => setTimeout(r, 450));
|
||||
|
||||
const all: Cluster[] = [
|
||||
{
|
||||
similarity: 0.96,
|
||||
suggestedAction: 'merge',
|
||||
memories: [
|
||||
{
|
||||
id: 'm-001',
|
||||
content:
|
||||
'BUG FIX: Harmony parser dropped `final` channel tokens when tool call followed. Root cause: 5-layer fallback missed the final channel marker when channel switched mid-stream. Solution: added final-channel detector before tool-call pop. Files: src/parser/harmony.rs',
|
||||
nodeType: 'fact',
|
||||
tags: ['bug-fix', 'aimo3', 'parser'],
|
||||
retention: 0.91,
|
||||
createdAt: '2026-04-12T14:22:00Z',
|
||||
},
|
||||
{
|
||||
id: 'm-002',
|
||||
content:
|
||||
'Fixed Harmony parser final-channel bug — 5-layer fallback was missing the final channel marker when a tool call followed. Added detector before tool pop.',
|
||||
nodeType: 'fact',
|
||||
tags: ['bug-fix', 'aimo3'],
|
||||
retention: 0.64,
|
||||
createdAt: '2026-04-13T09:15:00Z',
|
||||
},
|
||||
{
|
||||
id: 'm-003',
|
||||
content:
|
||||
'Harmony parser: final channel dropped on tool-call. Patched the fallback stack.',
|
||||
nodeType: 'note',
|
||||
tags: ['parser'],
|
||||
retention: 0.38,
|
||||
createdAt: '2026-04-14T11:02:00Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
similarity: 0.88,
|
||||
suggestedAction: 'merge',
|
||||
memories: [
|
||||
{
|
||||
id: 'm-004',
|
||||
content:
|
||||
'DECISION: Use vLLM prefix caching at 0.35 gpu_memory_utilization for AIMO3 submissions. Alternatives considered: sglang (slower cold start), TensorRT-LLM (deployment friction).',
|
||||
nodeType: 'decision',
|
||||
tags: ['vllm', 'aimo3', 'inference'],
|
||||
retention: 0.84,
|
||||
createdAt: '2026-04-05T18:44:00Z',
|
||||
},
|
||||
{
|
||||
id: 'm-005',
|
||||
content:
|
||||
'Chose vLLM with prefix caching (0.35 mem util) over sglang and TensorRT-LLM for AIMO3 inference.',
|
||||
nodeType: 'decision',
|
||||
tags: ['vllm', 'aimo3'],
|
||||
retention: 0.72,
|
||||
createdAt: '2026-04-06T10:30:00Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
similarity: 0.83,
|
||||
suggestedAction: 'review',
|
||||
memories: [
|
||||
{
|
||||
id: 'm-006',
|
||||
content:
|
||||
'Sam prefers to ship one change per Kaggle submission — stacking changes destroyed signal at AIMO3 (30/50 regression from 12 stacked variables).',
|
||||
nodeType: 'pattern',
|
||||
tags: ['kaggle', 'methodology', 'aimo3'],
|
||||
retention: 0.88,
|
||||
createdAt: '2026-04-04T22:10:00Z',
|
||||
},
|
||||
{
|
||||
id: 'm-007',
|
||||
content:
|
||||
'One-variable-at-a-time rule: never stack multiple changes per submission. Paper 2603.27844 proves +/-2 points is noise.',
|
||||
nodeType: 'pattern',
|
||||
tags: ['kaggle', 'methodology'],
|
||||
retention: 0.67,
|
||||
createdAt: '2026-04-08T16:20:00Z',
|
||||
},
|
||||
{
|
||||
id: 'm-008',
|
||||
content: 'Lesson: stacking 12 changes at AIMO3 cost a submission. Always isolate variables.',
|
||||
nodeType: 'note',
|
||||
tags: ['methodology'],
|
||||
retention: 0.42,
|
||||
createdAt: '2026-04-15T08:55:00Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
similarity: 0.78,
|
||||
suggestedAction: 'review',
|
||||
memories: [
|
||||
{
|
||||
id: 'm-009',
|
||||
content:
|
||||
'Dimensional Illusion performance: 7-minute flow poi set, LED config Parthenos overcook preset, tempo 128 BPM.',
|
||||
nodeType: 'event',
|
||||
tags: ['dimensional-illusion', 'poi', 'performance'],
|
||||
retention: 0.76,
|
||||
createdAt: '2026-03-28T19:45:00Z',
|
||||
},
|
||||
{
|
||||
id: 'm-010',
|
||||
content: 'Dimensional Illusion set: 7 min, Parthenos LED overcook, 128 BPM.',
|
||||
nodeType: 'event',
|
||||
tags: ['dimensional-illusion', 'poi'],
|
||||
retention: 0.51,
|
||||
createdAt: '2026-04-02T12:12:00Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
similarity: 0.76,
|
||||
suggestedAction: 'review',
|
||||
memories: [
|
||||
{
|
||||
id: 'm-011',
|
||||
content:
|
||||
'Vestige v2.0.7 shipped active forgetting via Anderson 2025 top-down inhibition + Davis Rac1 cascade. Suppress compounds, reversible 24h.',
|
||||
nodeType: 'fact',
|
||||
tags: ['vestige', 'release', 'active-forgetting'],
|
||||
retention: 0.93,
|
||||
createdAt: '2026-04-17T03:22:00Z',
|
||||
},
|
||||
{
|
||||
id: 'm-012',
|
||||
content:
|
||||
'Active Forgetting feature: compounds on each suppress, 24h reversible labile window, violet implosion animation in graph view.',
|
||||
nodeType: 'concept',
|
||||
tags: ['vestige', 'active-forgetting'],
|
||||
retention: 0.81,
|
||||
createdAt: '2026-04-18T09:07:00Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return { clusters: filterByThreshold(all, t) };
|
||||
}
|
||||
|
||||
async function detect() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
// TODO: swap for real endpoint /api/duplicates when backend ships.
|
||||
// See comment on mockFetchDuplicates for the exact replacement.
|
||||
const res = await mockFetchDuplicates(threshold);
|
||||
clusters = res.clusters;
|
||||
// Prune dismissals whose clusters no longer exist — prevents
|
||||
// unbounded growth across sessions and keeps the set honest.
|
||||
const presentKeys = new Set(clusters.map((c) => clusterKey(c.memories)));
|
||||
const pruned = new Set<string>();
|
||||
for (const k of dismissed) if (presentKeys.has(k)) pruned.add(k);
|
||||
dismissed = pruned;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to detect duplicates';
|
||||
clusters = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onThresholdChange() {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(detect, 250);
|
||||
}
|
||||
|
||||
function dismissCluster(key: string) {
|
||||
const next = new Set(dismissed);
|
||||
next.add(key);
|
||||
dismissed = next;
|
||||
}
|
||||
|
||||
function mergeCluster(key: string, winnerId: string, loserIds: string[]) {
|
||||
// TODO: POST /api/duplicates/merge { winner, losers } when backend ships.
|
||||
// For now we optimistically dismiss the cluster so the UI reflects the
|
||||
// action and rerun counts stay consistent.
|
||||
console.log('Merge cluster', key, { winnerId, loserIds });
|
||||
dismissCluster(key);
|
||||
}
|
||||
|
||||
const visibleClusters = $derived(
|
||||
clusters
|
||||
.map((c) => ({ c, key: clusterKey(c.memories) }))
|
||||
.filter(({ key }) => !dismissed.has(key))
|
||||
);
|
||||
|
||||
const totalDuplicates = $derived(
|
||||
visibleClusters.reduce((sum, { c }) => sum + c.memories.length, 0)
|
||||
);
|
||||
|
||||
// Cluster overflow: >50 would saturate the scroll. Show a warning and cap.
|
||||
const CLUSTER_RENDER_CAP = 50;
|
||||
const overflowed = $derived(visibleClusters.length > CLUSTER_RENDER_CAP);
|
||||
const renderedClusters = $derived(
|
||||
overflowed ? visibleClusters.slice(0, CLUSTER_RENDER_CAP) : visibleClusters
|
||||
);
|
||||
|
||||
onMount(() => detect());
|
||||
onDestroy(() => clearTimeout(debounceTimer));
|
||||
</script>
|
||||
|
||||
<div class="relative mx-auto max-w-5xl space-y-6 p-6">
|
||||
<!-- Header -->
|
||||
<header class="space-y-2">
|
||||
<h1 class="text-xl font-semibold text-bright">
|
||||
Memory Hygiene — Duplicate Detection
|
||||
</h1>
|
||||
<p class="text-sm text-dim">
|
||||
Cosine-similarity clustering over embeddings. Merges reinforce the winner's FSRS state;
|
||||
losers inherit into the merged node. Dismissed clusters are hidden for this session only.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<!-- Controls panel -->
|
||||
<div class="glass-panel flex flex-wrap items-center gap-5 rounded-2xl p-4">
|
||||
<!-- Threshold slider -->
|
||||
<label class="flex flex-1 min-w-64 items-center gap-3 text-xs text-dim">
|
||||
<span class="whitespace-nowrap">Similarity threshold</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0.70"
|
||||
max="0.95"
|
||||
step="0.01"
|
||||
bind:value={threshold}
|
||||
oninput={onThresholdChange}
|
||||
class="flex-1 accent-synapse"
|
||||
aria-label="Similarity threshold"
|
||||
/>
|
||||
<span class="w-14 text-right font-mono text-sm text-bright">
|
||||
{(threshold * 100).toFixed(0)}%
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<!-- Results pill -->
|
||||
<div
|
||||
class="flex items-center gap-2 rounded-full border border-synapse/20 bg-synapse/10 px-3 py-1.5 text-xs text-text"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{#if loading}
|
||||
<span class="h-2 w-2 animate-pulse rounded-full bg-synapse-glow"></span>
|
||||
<span>Detecting…</span>
|
||||
{:else if error}
|
||||
<span class="h-2 w-2 rounded-full bg-decay"></span>
|
||||
<span class="text-decay">Error</span>
|
||||
{:else}
|
||||
<span class="h-2 w-2 rounded-full bg-synapse-glow"></span>
|
||||
<span>
|
||||
{visibleClusters.length}
|
||||
{visibleClusters.length === 1 ? 'cluster' : 'clusters'},
|
||||
{totalDuplicates} potential duplicate{totalDuplicates === 1 ? '' : 's'}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={detect}
|
||||
disabled={loading}
|
||||
class="rounded-lg bg-white/[0.04] px-3 py-1.5 text-xs text-dim transition hover:bg-white/[0.08] hover:text-text disabled:opacity-40 focus:outline-none focus-visible:ring-2 focus-visible:ring-synapse/60"
|
||||
>
|
||||
Rerun
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Results -->
|
||||
{#if error}
|
||||
<div
|
||||
class="glass-panel flex flex-col items-center gap-3 rounded-2xl p-10 text-center"
|
||||
>
|
||||
<div class="text-sm text-decay">Couldn't detect duplicates</div>
|
||||
<div class="max-w-md text-xs text-muted">{error}</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={detect}
|
||||
class="mt-2 rounded-lg bg-synapse/20 px-4 py-2 text-xs font-medium text-synapse-glow transition hover:bg-synapse/30 focus:outline-none focus-visible:ring-2 focus-visible:ring-synapse/60"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
{:else if loading}
|
||||
<div class="space-y-3">
|
||||
{#each Array(3) as _}
|
||||
<div class="glass-subtle h-40 animate-pulse rounded-2xl"></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if visibleClusters.length === 0}
|
||||
<div
|
||||
class="glass-panel flex flex-col items-center gap-2 rounded-2xl p-12 text-center"
|
||||
>
|
||||
<div class="text-3xl">·</div>
|
||||
<div class="text-sm font-medium text-bright">
|
||||
No duplicates found above threshold.
|
||||
</div>
|
||||
<div class="text-xs text-muted">Memory is clean.</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
{#if overflowed}
|
||||
<div
|
||||
class="glass-subtle rounded-xl border border-warning/30 bg-warning/5 px-4 py-2 text-xs text-dim"
|
||||
>
|
||||
Showing first {CLUSTER_RENDER_CAP} of {visibleClusters.length} clusters. Raise the
|
||||
threshold to narrow results.
|
||||
</div>
|
||||
{/if}
|
||||
{#each renderedClusters as { c, key } (key)}
|
||||
<div class="animate-[fadeSlide_0.35s_ease-out_both]">
|
||||
<DuplicateCluster
|
||||
similarity={c.similarity}
|
||||
memories={c.memories}
|
||||
suggestedAction={c.suggestedAction}
|
||||
onDismiss={() => dismissCluster(key)}
|
||||
onMerge={(winnerId, loserIds) => mergeCluster(key, winnerId, loserIds)}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes fadeSlide {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -7,6 +7,7 @@
|
|||
import MemoryStateLegend from '$components/MemoryStateLegend.svelte';
|
||||
import { api } from '$stores/api';
|
||||
import { eventFeed } from '$stores/websocket';
|
||||
import { graphState } from '$stores/graph-state.svelte';
|
||||
import type { GraphResponse, GraphNode, GraphEdge, Memory } from '$types';
|
||||
import type { GraphMutation } from '$lib/graph/events';
|
||||
import type { ColorMode } from '$lib/graph/nodes';
|
||||
|
|
@ -83,37 +84,81 @@
|
|||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
const isDefault = !query && !centerId;
|
||||
graphData = await api.graph({
|
||||
max_nodes: maxNodes,
|
||||
depth: 3,
|
||||
query: query || undefined,
|
||||
center_id: centerId || undefined
|
||||
center_id: centerId || undefined,
|
||||
// Center on the newest memory by default. Prevents the old
|
||||
// "most-connected" behaviour from clustering on historical
|
||||
// hotspots and hiding today's memories behind the 150-node
|
||||
// cap. Future UI toggle can flip this to 'connected'.
|
||||
sort: isDefault ? 'recent' : undefined
|
||||
});
|
||||
|
||||
// Fallback: if the newest memory is isolated (1 node, 0 edges),
|
||||
// fall back to the connected hotspot so the user sees context
|
||||
// instead of a lonely orb. Only applies to the default load —
|
||||
// explicit queries/centerId honor the user's choice even if the
|
||||
// subgraph is sparse.
|
||||
if (
|
||||
isDefault &&
|
||||
graphData &&
|
||||
graphData.nodeCount <= 1 &&
|
||||
graphData.edgeCount === 0
|
||||
) {
|
||||
const connected = await api.graph({
|
||||
max_nodes: maxNodes,
|
||||
depth: 3,
|
||||
sort: 'connected'
|
||||
});
|
||||
if (connected && connected.nodeCount > graphData.nodeCount) {
|
||||
graphData = connected;
|
||||
}
|
||||
}
|
||||
|
||||
if (graphData) {
|
||||
liveNodeCount = graphData.nodeCount;
|
||||
liveEdgeCount = graphData.edgeCount;
|
||||
}
|
||||
} catch (e) {
|
||||
// Distinguish "cold-start / empty database" from "actual API failure".
|
||||
// Before v2.0.7 both surfaced as "No memories yet..." which masked
|
||||
// real errors (network down, dashboard disabled, 500s) and looked
|
||||
// identical to a first-run install. Split the two so debugging
|
||||
// isn't a guessing game.
|
||||
//
|
||||
// Sanitize the error string before rendering: strip filesystem
|
||||
// paths and crate-file references (the backend occasionally wraps
|
||||
// raw rusqlite / fs errors) and cap length at 200 chars so a
|
||||
// stack-trace-sized error doesn't dominate the page.
|
||||
// Distinguish three failure modes so the error message is actually
|
||||
// helpful. Before: all failures (backend offline, empty DB, real
|
||||
// 500) rendered identical cryptic text. That made the dashboard
|
||||
// look broken on first-run or on backend-down, when the root
|
||||
// cause is ALWAYS "the MCP server isn't running."
|
||||
// (1) Backend offline — vite dev proxy returns 500 with no body
|
||||
// (upstream EHOSTUNREACH / ECONNREFUSED). Surface clearly:
|
||||
// tell the user to start vestige-mcp.
|
||||
// (2) Empty database — fresh install, no memories yet. Happy
|
||||
// first-run state, not an error.
|
||||
// (3) Real backend error — a genuine 500 with a response body,
|
||||
// or a 4xx with content. Show the sanitized upstream msg.
|
||||
const rawMsg = e instanceof Error ? e.message : String(e);
|
||||
const safeMsg = rawMsg
|
||||
.replace(/\/[\w./-]+\.(sqlite|rs|db|toml|lock)\b/g, '[path]')
|
||||
.slice(0, 200);
|
||||
|
||||
// Network-level failure: fetch itself rejects (TypeError) OR vite
|
||||
// proxy passes back a body-less 500 when upstream :3927 is
|
||||
// unreachable. Both mean "backend offline."
|
||||
const isBackendOffline =
|
||||
e instanceof TypeError ||
|
||||
/failed to fetch|NetworkError|load failed/i.test(rawMsg) ||
|
||||
/^API 500:?\s*(Internal Server Error)?\s*$/i.test(rawMsg.trim());
|
||||
|
||||
const isEmpty =
|
||||
(graphData?.nodeCount ?? 0) === 0 &&
|
||||
/not found|404|empty|no memor/i.test(rawMsg);
|
||||
error = isEmpty
|
||||
? 'No memories yet. Start using Vestige to populate your graph.'
|
||||
: `Failed to load graph: ${safeMsg}`;
|
||||
|
||||
if (isBackendOffline) {
|
||||
error = 'OFFLINE';
|
||||
} else if (isEmpty) {
|
||||
error = 'EMPTY';
|
||||
} else {
|
||||
error = `Failed to load graph: ${safeMsg}`;
|
||||
}
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
|
|
@ -149,6 +194,40 @@
|
|||
<p class="text-dim text-sm">Loading memory graph...</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else if error === 'OFFLINE'}
|
||||
<div class="h-full flex items-center justify-center">
|
||||
<div class="text-center space-y-5 max-w-lg px-8">
|
||||
<div class="text-5xl opacity-40">⚡</div>
|
||||
<h2 class="text-xl text-bright">MCP Backend Offline</h2>
|
||||
<p class="text-dim text-sm leading-relaxed">
|
||||
The Vestige MCP server isn't reachable on <code class="font-mono text-muted">:3927</code>.
|
||||
The dashboard is running but has nothing to query.
|
||||
</p>
|
||||
<div class="glass-subtle rounded-xl p-4 text-left text-xs font-mono text-dim space-y-2">
|
||||
<div class="text-muted text-[10px] uppercase tracking-wider">Start the backend:</div>
|
||||
<code class="block whitespace-pre-wrap break-all text-text">nohup bash -c 'tail -f /dev/null | VESTIGE_DASHBOARD_ENABLED=true ~/.local/bin/vestige-mcp' > /tmp/vestige.log 2>&1 &
|
||||
disown</code>
|
||||
</div>
|
||||
<div class="flex gap-2 justify-center">
|
||||
<button onclick={() => loadGraph()}
|
||||
class="px-4 py-2 bg-synapse/20 border border-synapse/40 text-synapse-glow text-xs rounded-xl hover:bg-synapse/30 transition">
|
||||
Retry
|
||||
</button>
|
||||
<a href="{base}/settings"
|
||||
class="px-4 py-2 bg-dream/20 border border-dream/40 text-dream-glow text-xs rounded-xl hover:bg-dream/30 transition">
|
||||
Try demos (no backend needed)
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if error === 'EMPTY'}
|
||||
<div class="h-full flex items-center justify-center">
|
||||
<div class="text-center space-y-4 max-w-md px-8">
|
||||
<div class="text-5xl opacity-30">◎</div>
|
||||
<h2 class="text-xl text-bright">Your Mind Awaits</h2>
|
||||
<p class="text-dim text-sm">No memories yet. Start using Vestige to populate your graph.</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="h-full flex items-center justify-center">
|
||||
<div class="text-center space-y-4 max-w-md px-8">
|
||||
|
|
@ -224,6 +303,27 @@
|
|||
<option value={200}>200 nodes</option>
|
||||
</select>
|
||||
|
||||
<!-- Brightness slider (persists in localStorage). Scales node emissive,
|
||||
glow, and distance-compensated fog falloff. Default 1.0, range 0.5-2.5. -->
|
||||
<label
|
||||
class="flex items-center gap-2 px-3 py-2 glass rounded-xl text-dim text-xs select-none"
|
||||
title="Adjust graph brightness ({graphState.brightness.toFixed(1)}x). Combines with auto distance compensation."
|
||||
>
|
||||
<span class="text-synapse-glow">☀</span>
|
||||
<input
|
||||
type="range"
|
||||
min={graphState.brightnessMin}
|
||||
max={graphState.brightnessMax}
|
||||
step="0.1"
|
||||
bind:value={graphState.brightness}
|
||||
class="w-20 accent-synapse cursor-pointer"
|
||||
aria-label="Graph brightness"
|
||||
/>
|
||||
<span class="font-mono text-[10px] text-muted w-8 text-right">
|
||||
{graphState.brightness.toFixed(1)}x
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<!-- Dream button -->
|
||||
<button
|
||||
onclick={triggerDream}
|
||||
|
|
|
|||
330
apps/dashboard/src/routes/(app)/importance/+page.svelte
Normal file
330
apps/dashboard/src/routes/(app)/importance/+page.svelte
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { base } from '$app/paths';
|
||||
import { api } from '$stores/api';
|
||||
import type { ImportanceScore, Memory } from '$types';
|
||||
import { NODE_TYPE_COLORS } from '$types';
|
||||
import ImportanceRadar from '$components/ImportanceRadar.svelte';
|
||||
|
||||
// ── Section 1: Test Importance ───────────────────────────────────────────
|
||||
let content = $state('');
|
||||
let score: ImportanceScore | null = $state(null);
|
||||
let scoring = $state(false);
|
||||
let scoreError: string | null = $state(null);
|
||||
|
||||
// Keyed radar remount — we flip the key each time a new score lands so the
|
||||
// onMount grow-from-center animation re-fires instead of just mutating props.
|
||||
let radarKey = $state(0);
|
||||
|
||||
async function scoreContent() {
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed || scoring) return;
|
||||
scoring = true;
|
||||
scoreError = null;
|
||||
try {
|
||||
score = await api.importance(trimmed);
|
||||
radarKey++;
|
||||
} catch (e) {
|
||||
scoreError = e instanceof Error ? e.message : String(e);
|
||||
score = null;
|
||||
} finally {
|
||||
scoring = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
// Cmd/Ctrl+Enter submits so the power-user flow isn't "click the button".
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
scoreContent();
|
||||
}
|
||||
}
|
||||
|
||||
// Which channel contributed the most to the composite? Drives the "why"
|
||||
// blurb under the recommendation. Uses the same weights ImportanceSignals
|
||||
// applies server-side (novelty 0.25 / arousal 0.30 / reward 0.25 / attention 0.20)
|
||||
// so the explanation lines up with the composite.
|
||||
const CHANNEL_WEIGHTS = { novelty: 0.25, arousal: 0.3, reward: 0.25, attention: 0.2 } as const;
|
||||
type ChannelKey = keyof typeof CHANNEL_WEIGHTS;
|
||||
|
||||
const CHANNEL_BLURBS: Record<ChannelKey, { high: string; low: string }> = {
|
||||
novelty: {
|
||||
high: 'new information not already in your graph',
|
||||
low: 'overlaps heavily with what you already know'
|
||||
},
|
||||
arousal: {
|
||||
high: 'emotionally salient — decisions, bugs, or discoveries stick',
|
||||
low: 'neutral tone, no strong affect signal'
|
||||
},
|
||||
reward: {
|
||||
high: 'high reward value — preferences, wins, or solutions you will revisit',
|
||||
low: 'low reward value — transient or incidental detail'
|
||||
},
|
||||
attention: {
|
||||
high: 'strong attentional markers (imperatives, questions, urgency)',
|
||||
low: 'passive phrasing, no clear attentional hook'
|
||||
}
|
||||
};
|
||||
|
||||
let topChannel = $derived.by<{ key: ChannelKey; contribution: number } | null>(() => {
|
||||
if (!score) return null;
|
||||
const ranked = (Object.keys(CHANNEL_WEIGHTS) as ChannelKey[])
|
||||
.map((k) => ({ key: k, contribution: score!.channels[k] * CHANNEL_WEIGHTS[k] }))
|
||||
.sort((a, b) => b.contribution - a.contribution);
|
||||
return ranked[0];
|
||||
});
|
||||
|
||||
let weakestChannel = $derived.by<ChannelKey | null>(() => {
|
||||
if (!score) return null;
|
||||
return (Object.keys(CHANNEL_WEIGHTS) as ChannelKey[])
|
||||
.slice()
|
||||
.sort((a, b) => score!.channels[a] - score!.channels[b])[0];
|
||||
});
|
||||
|
||||
// ── Section 2: Top Important Memories This Week ──────────────────────────
|
||||
// The Memory response does NOT include the per-memory importance channels,
|
||||
// so we approximate a "trending importance" proxy from the FSRS state we
|
||||
// DO have: retention strength × (1 + reviewCount) × recency-boost. Clients
|
||||
// who want the true composite would need the backend to include channels.
|
||||
// TODO: backend should include channels on Memory response directly
|
||||
let memories: Memory[] = $state([]);
|
||||
let loadingMemories = $state(true);
|
||||
// Per-memory radar channels, fetched lazily via api.importance(content).
|
||||
// Keyed by memory.id. Until populated, mini-radars render with zeroed props.
|
||||
let perMemoryScores: Record<string, ImportanceScore['channels']> = $state({});
|
||||
|
||||
function importanceProxy(m: Memory): number {
|
||||
// retentionStrength × log(1 + reviewCount) / age_days.
|
||||
// Heavy short-term bias so the "this week" framing actually holds.
|
||||
const ageDays = Math.max(
|
||||
1,
|
||||
(Date.now() - new Date(m.createdAt).getTime()) / 86_400_000
|
||||
);
|
||||
const reviews = m.reviewCount ?? 0;
|
||||
const recencyBoost = 1 / Math.pow(ageDays, 0.5);
|
||||
return m.retentionStrength * Math.log1p(reviews + 1) * recencyBoost;
|
||||
}
|
||||
|
||||
async function loadTrending() {
|
||||
loadingMemories = true;
|
||||
try {
|
||||
const res = await api.memories.list({ limit: '20' });
|
||||
// Sort client-side by our proxy, keep top 20.
|
||||
const ranked = res.memories
|
||||
.slice()
|
||||
.sort((a, b) => importanceProxy(b) - importanceProxy(a))
|
||||
.slice(0, 20);
|
||||
memories = ranked;
|
||||
// Lazily score each one so the mini-radars aren't all zeros. We fan
|
||||
// these out in parallel but don't await them before painting — the
|
||||
// list renders immediately and radars fill in as results arrive.
|
||||
memories.forEach(async (m) => {
|
||||
try {
|
||||
const s = await api.importance(m.content);
|
||||
perMemoryScores[m.id] = s.channels;
|
||||
} catch {
|
||||
// swallow — per-memory score is cosmetic, list still works
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
memories = [];
|
||||
} finally {
|
||||
loadingMemories = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(loadTrending);
|
||||
|
||||
function openMemory(id: string) {
|
||||
// The memories page doesn't support deep-linking to a specific memory
|
||||
// yet; navigate there and let the user scroll. base is '/dashboard'.
|
||||
goto(`${base}/memories`);
|
||||
void id;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-6 max-w-5xl mx-auto space-y-8">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-xl text-bright font-semibold">Importance Radar</h1>
|
||||
<p class="text-sm text-dim mt-1">
|
||||
4-channel importance model: Novelty · Arousal · Reward · Attention
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Section 1: Test Importance ─────────────────────────────────────── -->
|
||||
<section class="glass-panel rounded-2xl p-6 space-y-5">
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold text-bright uppercase tracking-wider">Test Importance</h2>
|
||||
<p class="text-xs text-muted mt-1">
|
||||
Paste any content below. Vestige scores it across 4 channels and
|
||||
decides whether it is worth saving.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid md:grid-cols-[1fr_auto] gap-5 items-start">
|
||||
<div class="space-y-3">
|
||||
<textarea
|
||||
bind:value={content}
|
||||
onkeydown={onKeydown}
|
||||
placeholder="Type some content above to score its importance."
|
||||
class="w-full min-h-40 px-4 py-3 bg-white/[0.03] border border-synapse/10 rounded-xl text-text text-sm
|
||||
placeholder:text-muted focus:outline-none focus:border-synapse/40 focus:ring-1 focus:ring-synapse/20
|
||||
transition backdrop-blur-sm resize-y font-mono"
|
||||
></textarea>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
onclick={scoreContent}
|
||||
disabled={scoring || !content.trim()}
|
||||
class="px-4 py-2 bg-synapse/20 text-synapse-glow text-sm rounded-xl border border-synapse/30
|
||||
hover:bg-synapse/30 hover:border-synapse/50 transition disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{scoring ? 'Scoring…' : 'Score Importance'}
|
||||
</button>
|
||||
<span class="text-xs text-muted">⌘/Ctrl + Enter</span>
|
||||
{#if scoreError}
|
||||
<span class="text-xs text-decay">{scoreError}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Radar + composite readout -->
|
||||
<div class="flex flex-col items-center gap-4 md:min-w-[340px]">
|
||||
{#if score}
|
||||
<div class="text-center">
|
||||
<div class="text-[10px] uppercase tracking-widest text-muted">Composite</div>
|
||||
<div class="text-5xl font-semibold text-bright leading-none mt-1">
|
||||
{(score.composite * 100).toFixed(0)}<span class="text-xl text-dim">%</span>
|
||||
</div>
|
||||
</div>
|
||||
{#key radarKey}
|
||||
<ImportanceRadar
|
||||
novelty={score.channels.novelty}
|
||||
arousal={score.channels.arousal}
|
||||
reward={score.channels.reward}
|
||||
attention={score.channels.attention}
|
||||
size="lg"
|
||||
/>
|
||||
{/key}
|
||||
|
||||
<!-- Recommendation -->
|
||||
{#if score.composite > 0.6}
|
||||
<div class="w-full text-center space-y-1">
|
||||
<div class="text-lg font-semibold text-recall">✓ Save</div>
|
||||
<p class="text-xs text-dim leading-relaxed">
|
||||
Composite {(score.composite * 100).toFixed(0)}% > 60% threshold.
|
||||
{#if topChannel}
|
||||
Driven by <span class="text-bright">{topChannel.key}</span> — {CHANNEL_BLURBS[topChannel.key].high}.
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="w-full text-center space-y-1">
|
||||
<div class="text-lg font-semibold text-decay">⨯ Skip</div>
|
||||
<p class="text-xs text-dim leading-relaxed">
|
||||
Composite {(score.composite * 100).toFixed(0)}% < 60% threshold.
|
||||
{#if weakestChannel}
|
||||
Weakest channel: <span class="text-bright">{weakestChannel}</span> — {CHANNEL_BLURBS[weakestChannel].low}.
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="flex flex-col items-center justify-center min-h-[320px] w-full text-center px-4">
|
||||
<div class="text-3xl text-muted mb-3">◫</div>
|
||||
<p class="text-sm text-dim">Type some content above to score its importance.</p>
|
||||
<p class="text-xs text-muted mt-2 max-w-xs">
|
||||
Composite = 0.25·novelty + 0.30·arousal + 0.25·reward + 0.20·attention.
|
||||
Threshold for save: 60%.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Section 2: Top Important Memories This Week ────────────────────── -->
|
||||
<section class="space-y-4">
|
||||
<div class="flex items-end justify-between">
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold text-bright uppercase tracking-wider">
|
||||
Top Important Memories This Week
|
||||
</h2>
|
||||
<p class="text-xs text-muted mt-1">
|
||||
Ranked by retention × reviews ÷ age. Click any card to open it.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={loadTrending}
|
||||
class="text-xs text-muted hover:text-text transition"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if loadingMemories}
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
{#each Array(6) as _}
|
||||
<div class="h-28 glass-subtle rounded-xl animate-pulse"></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if memories.length === 0}
|
||||
<div class="text-center py-12 text-dim">
|
||||
<p class="text-sm">No memories yet.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
{#each memories as memory (memory.id)}
|
||||
{@const ch = perMemoryScores[memory.id]}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => openMemory(memory.id)}
|
||||
class="text-left p-4 glass-subtle rounded-xl hover:bg-white/[0.04] hover:border-synapse/30
|
||||
transition-all duration-200 flex items-start gap-4"
|
||||
>
|
||||
<div class="flex-1 min-w-0 space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="w-2 h-2 rounded-full"
|
||||
style="background: {NODE_TYPE_COLORS[memory.nodeType] || '#8B95A5'}"
|
||||
></span>
|
||||
<span class="text-xs text-dim">{memory.nodeType}</span>
|
||||
<span class="text-xs text-muted">·</span>
|
||||
<span class="text-xs text-muted">
|
||||
{(memory.retentionStrength * 100).toFixed(0)}% retention
|
||||
</span>
|
||||
{#if memory.reviewCount}
|
||||
<span class="text-xs text-muted">·</span>
|
||||
<span class="text-xs text-muted">{memory.reviewCount} reviews</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-sm text-text leading-relaxed line-clamp-3">
|
||||
{memory.content}
|
||||
</p>
|
||||
{#if memory.tags.length > 0}
|
||||
<div class="flex gap-1.5 flex-wrap">
|
||||
{#each memory.tags.slice(0, 4) as tag}
|
||||
<span class="text-[10px] px-1.5 py-0.5 bg-white/[0.04] rounded text-muted">
|
||||
{tag}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex-shrink-0">
|
||||
<ImportanceRadar
|
||||
novelty={ch?.novelty ?? 0}
|
||||
arousal={ch?.arousal ?? 0}
|
||||
reward={ch?.reward ?? 0}
|
||||
attention={ch?.attention ?? 0}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
import { api } from '$stores/api';
|
||||
import type { Memory } from '$types';
|
||||
import { NODE_TYPE_COLORS } from '$types';
|
||||
import MemoryAuditTrail from '$lib/components/MemoryAuditTrail.svelte';
|
||||
|
||||
let memories: Memory[] = $state([]);
|
||||
let searchQuery = $state('');
|
||||
|
|
@ -11,6 +12,9 @@
|
|||
let minRetention = $state(0);
|
||||
let loading = $state(true);
|
||||
let selectedMemory: Memory | null = $state(null);
|
||||
// Which inner tab of the expanded card is active. Keyed by memory id so
|
||||
// switching between cards remembers each one's last view independently.
|
||||
let expandedTab: Record<string, 'content' | 'audit'> = $state({});
|
||||
let debounceTimer: ReturnType<typeof setTimeout>;
|
||||
|
||||
onMount(() => loadMemories());
|
||||
|
|
@ -116,13 +120,45 @@
|
|||
</div>
|
||||
|
||||
{#if selectedMemory?.id === memory.id}
|
||||
{@const activeTab = expandedTab[memory.id] ?? 'content'}
|
||||
<div class="mt-4 pt-4 border-t border-synapse/10 space-y-3">
|
||||
<p class="text-sm text-text whitespace-pre-wrap">{memory.content}</p>
|
||||
<div class="grid grid-cols-3 gap-3 text-xs text-dim">
|
||||
<div>Storage: {(memory.storageStrength * 100).toFixed(1)}%</div>
|
||||
<div>Retrieval: {(memory.retrievalStrength * 100).toFixed(1)}%</div>
|
||||
<div>Created: {new Date(memory.createdAt).toLocaleDateString()}</div>
|
||||
<!-- Inner tab switcher: Content (default) vs Audit Trail. -->
|
||||
<div class="flex gap-1 text-[11px] uppercase tracking-wider">
|
||||
<span
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={(e) => { e.stopPropagation(); expandedTab[memory.id] = 'content'; }}
|
||||
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.stopPropagation(); expandedTab[memory.id] = 'content'; } }}
|
||||
class="px-3 py-1.5 rounded-lg cursor-pointer select-none transition
|
||||
{activeTab === 'content' ? 'bg-synapse/20 text-synapse-glow border border-synapse/40' : 'bg-white/[0.03] text-dim hover:text-text border border-transparent'}"
|
||||
>Content</span>
|
||||
<span
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={(e) => { e.stopPropagation(); expandedTab[memory.id] = 'audit'; }}
|
||||
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.stopPropagation(); expandedTab[memory.id] = 'audit'; } }}
|
||||
class="px-3 py-1.5 rounded-lg cursor-pointer select-none transition
|
||||
{activeTab === 'audit' ? 'bg-synapse/20 text-synapse-glow border border-synapse/40' : 'bg-white/[0.03] text-dim hover:text-text border border-transparent'}"
|
||||
>Audit Trail</span>
|
||||
</div>
|
||||
|
||||
{#if activeTab === 'content'}
|
||||
<p class="text-sm text-text whitespace-pre-wrap">{memory.content}</p>
|
||||
<div class="grid grid-cols-3 gap-3 text-xs text-dim">
|
||||
<div>Storage: {(memory.storageStrength * 100).toFixed(1)}%</div>
|
||||
<div>Retrieval: {(memory.retrievalStrength * 100).toFixed(1)}%</div>
|
||||
<div>Created: {new Date(memory.createdAt).toLocaleDateString()}</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
role="presentation"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MemoryAuditTrail memoryId={memory.id} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-2">
|
||||
<span role="button" tabindex="0" onclick={(e) => { e.stopPropagation(); api.memories.promote(memory.id); }}
|
||||
onkeydown={(e) => { if (e.key === 'Enter') { e.stopPropagation(); api.memories.promote(memory.id); } }}
|
||||
|
|
|
|||
567
apps/dashboard/src/routes/(app)/patterns/+page.svelte
Normal file
567
apps/dashboard/src/routes/(app)/patterns/+page.svelte
Normal file
|
|
@ -0,0 +1,567 @@
|
|||
<!--
|
||||
Cross-Project Intelligence — Pattern Transfer Heatmap
|
||||
Dashboard exposure of the CrossProjectLearner backend state. Visualizes
|
||||
which coding patterns were learned in one project and reused in another,
|
||||
across all six tracked categories (ErrorHandling, AsyncConcurrency, Testing,
|
||||
Architecture, Performance, Security).
|
||||
|
||||
Category tabs filter the pattern set. Heatmap cell click filters the
|
||||
"Top Transferred Patterns" sidebar to a specific origin → destination pair.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import PatternTransferHeatmap from '$components/PatternTransferHeatmap.svelte';
|
||||
|
||||
type Category =
|
||||
| 'ErrorHandling'
|
||||
| 'AsyncConcurrency'
|
||||
| 'Testing'
|
||||
| 'Architecture'
|
||||
| 'Performance'
|
||||
| 'Security';
|
||||
|
||||
interface Pattern {
|
||||
name: string;
|
||||
category: Category;
|
||||
origin_project: string;
|
||||
transferred_to: string[];
|
||||
transfer_count: number;
|
||||
last_used: string;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
interface CrossProjectResponse {
|
||||
projects: string[];
|
||||
patterns: Pattern[];
|
||||
}
|
||||
|
||||
const CATEGORIES: readonly Category[] = [
|
||||
'ErrorHandling',
|
||||
'AsyncConcurrency',
|
||||
'Testing',
|
||||
'Architecture',
|
||||
'Performance',
|
||||
'Security'
|
||||
] as const;
|
||||
|
||||
const CATEGORY_COLORS: Record<Category, string> = {
|
||||
ErrorHandling: 'var(--color-decay)',
|
||||
AsyncConcurrency: 'var(--color-synapse-glow)',
|
||||
Testing: 'var(--color-recall)',
|
||||
Architecture: 'var(--color-dream-glow)',
|
||||
Performance: 'var(--color-warning)',
|
||||
Security: 'var(--color-node-pattern)'
|
||||
};
|
||||
|
||||
let activeCategory = $state<'All' | Category>('All');
|
||||
let data = $state<CrossProjectResponse>({ projects: [], patterns: [] });
|
||||
let loading = $state(true);
|
||||
let error: string | null = $state(null);
|
||||
let selectedCell = $state<{ from: string; to: string } | null>(null);
|
||||
|
||||
// TODO: swap for real fetch to /api/patterns/cross-project when backend ships.
|
||||
// The CrossProjectLearner already tracks these categories in Rust — exposing
|
||||
// it over HTTP is a straightforward map-to-DTO. Matching shape below so the
|
||||
// swap is a one-liner.
|
||||
async function mockFetchCrossProject(): Promise<CrossProjectResponse> {
|
||||
await new Promise((r) => setTimeout(r, 420));
|
||||
|
||||
const projects = [
|
||||
'vestige',
|
||||
'nullgaze',
|
||||
'injeranet',
|
||||
'nemotron',
|
||||
'orbit-wars',
|
||||
'nightvision',
|
||||
'aimo3'
|
||||
];
|
||||
|
||||
const patterns: Pattern[] = [
|
||||
// ErrorHandling — widely transferred
|
||||
{
|
||||
name: 'Result<T, E> with thiserror context',
|
||||
category: 'ErrorHandling',
|
||||
origin_project: 'vestige',
|
||||
transferred_to: ['nullgaze', 'injeranet', 'nemotron', 'nightvision'],
|
||||
transfer_count: 4,
|
||||
last_used: '2026-04-18T14:22:00Z',
|
||||
confidence: 0.94
|
||||
},
|
||||
{
|
||||
name: 'Axum error middleware with tower-http',
|
||||
category: 'ErrorHandling',
|
||||
origin_project: 'nullgaze',
|
||||
transferred_to: ['vestige', 'nightvision'],
|
||||
transfer_count: 2,
|
||||
last_used: '2026-04-17T09:10:00Z',
|
||||
confidence: 0.88
|
||||
},
|
||||
{
|
||||
name: 'Graceful shutdown on SIGINT/SIGTERM',
|
||||
category: 'ErrorHandling',
|
||||
origin_project: 'vestige',
|
||||
transferred_to: ['vestige', 'injeranet', 'nightvision'],
|
||||
transfer_count: 3,
|
||||
last_used: '2026-04-15T22:01:00Z',
|
||||
confidence: 0.82
|
||||
},
|
||||
{
|
||||
name: 'Python try/except with contextual re-raise',
|
||||
category: 'ErrorHandling',
|
||||
origin_project: 'aimo3',
|
||||
transferred_to: ['nemotron'],
|
||||
transfer_count: 1,
|
||||
last_used: '2026-04-10T11:30:00Z',
|
||||
confidence: 0.7
|
||||
},
|
||||
|
||||
// AsyncConcurrency
|
||||
{
|
||||
name: 'Arc<Mutex<Connection>> reader/writer split',
|
||||
category: 'AsyncConcurrency',
|
||||
origin_project: 'vestige',
|
||||
transferred_to: ['nullgaze', 'injeranet'],
|
||||
transfer_count: 2,
|
||||
last_used: '2026-04-14T16:42:00Z',
|
||||
confidence: 0.91
|
||||
},
|
||||
{
|
||||
name: 'tokio::select! for cancellation propagation',
|
||||
category: 'AsyncConcurrency',
|
||||
origin_project: 'injeranet',
|
||||
transferred_to: ['vestige', 'nightvision'],
|
||||
transfer_count: 2,
|
||||
last_used: '2026-04-19T08:05:00Z',
|
||||
confidence: 0.86
|
||||
},
|
||||
{
|
||||
name: 'Bounded mpsc channel with backpressure',
|
||||
category: 'AsyncConcurrency',
|
||||
origin_project: 'injeranet',
|
||||
transferred_to: ['vestige', 'nullgaze'],
|
||||
transfer_count: 2,
|
||||
last_used: '2026-04-12T13:18:00Z',
|
||||
confidence: 0.83
|
||||
},
|
||||
{
|
||||
name: 'asyncio.gather with return_exceptions',
|
||||
category: 'AsyncConcurrency',
|
||||
origin_project: 'nemotron',
|
||||
transferred_to: ['aimo3'],
|
||||
transfer_count: 1,
|
||||
last_used: '2026-04-08T20:45:00Z',
|
||||
confidence: 0.72
|
||||
},
|
||||
|
||||
// Testing
|
||||
{
|
||||
name: 'Property-based tests with proptest',
|
||||
category: 'Testing',
|
||||
origin_project: 'vestige',
|
||||
transferred_to: ['nullgaze', 'injeranet'],
|
||||
transfer_count: 2,
|
||||
last_used: '2026-04-11T10:22:00Z',
|
||||
confidence: 0.89
|
||||
},
|
||||
{
|
||||
name: 'Snapshot testing with insta',
|
||||
category: 'Testing',
|
||||
origin_project: 'nullgaze',
|
||||
transferred_to: ['vestige'],
|
||||
transfer_count: 1,
|
||||
last_used: '2026-04-16T14:00:00Z',
|
||||
confidence: 0.81
|
||||
},
|
||||
{
|
||||
name: 'Vitest + Playwright dashboard harness',
|
||||
category: 'Testing',
|
||||
origin_project: 'vestige',
|
||||
transferred_to: ['nullgaze', 'injeranet'],
|
||||
transfer_count: 2,
|
||||
last_used: '2026-04-19T18:30:00Z',
|
||||
confidence: 0.87
|
||||
},
|
||||
{
|
||||
name: 'One-variable-at-a-time Kaggle submission',
|
||||
category: 'Testing',
|
||||
origin_project: 'aimo3',
|
||||
transferred_to: ['nemotron', 'orbit-wars'],
|
||||
transfer_count: 2,
|
||||
last_used: '2026-04-20T07:15:00Z',
|
||||
confidence: 0.95
|
||||
},
|
||||
{
|
||||
name: 'Kaggle pre-flight Input-panel screenshot',
|
||||
category: 'Testing',
|
||||
origin_project: 'aimo3',
|
||||
transferred_to: ['nemotron', 'orbit-wars'],
|
||||
transfer_count: 2,
|
||||
last_used: '2026-04-20T06:50:00Z',
|
||||
confidence: 0.98
|
||||
},
|
||||
|
||||
// Architecture
|
||||
{
|
||||
name: 'SvelteKit 2 + Svelte 5 runes dashboard',
|
||||
category: 'Architecture',
|
||||
origin_project: 'vestige',
|
||||
transferred_to: ['nullgaze', 'nightvision'],
|
||||
transfer_count: 2,
|
||||
last_used: '2026-04-19T12:10:00Z',
|
||||
confidence: 0.92
|
||||
},
|
||||
{
|
||||
name: 'glass-panel + cosmic-dark design system',
|
||||
category: 'Architecture',
|
||||
origin_project: 'vestige',
|
||||
transferred_to: ['nullgaze', 'nightvision', 'injeranet'],
|
||||
transfer_count: 3,
|
||||
last_used: '2026-04-20T09:00:00Z',
|
||||
confidence: 0.9
|
||||
},
|
||||
{
|
||||
name: 'Tauri 2 + Rust/Axum sidecar',
|
||||
category: 'Architecture',
|
||||
origin_project: 'injeranet',
|
||||
transferred_to: ['nightvision'],
|
||||
transfer_count: 1,
|
||||
last_used: '2026-04-13T19:44:00Z',
|
||||
confidence: 0.78
|
||||
},
|
||||
{
|
||||
name: 'MCP server with 23 stateful tools',
|
||||
category: 'Architecture',
|
||||
origin_project: 'vestige',
|
||||
transferred_to: ['injeranet'],
|
||||
transfer_count: 1,
|
||||
last_used: '2026-04-17T11:05:00Z',
|
||||
confidence: 0.85
|
||||
},
|
||||
|
||||
// Performance
|
||||
{
|
||||
name: 'USearch HNSW index for vector search',
|
||||
category: 'Performance',
|
||||
origin_project: 'vestige',
|
||||
transferred_to: ['nullgaze'],
|
||||
transfer_count: 1,
|
||||
last_used: '2026-04-09T15:20:00Z',
|
||||
confidence: 0.88
|
||||
},
|
||||
{
|
||||
name: 'SQLite WAL mode for concurrent reads',
|
||||
category: 'Performance',
|
||||
origin_project: 'vestige',
|
||||
transferred_to: ['nullgaze', 'injeranet', 'nightvision'],
|
||||
transfer_count: 3,
|
||||
last_used: '2026-04-18T21:33:00Z',
|
||||
confidence: 0.93
|
||||
},
|
||||
{
|
||||
name: 'vLLM prefix caching at 0.35 mem util',
|
||||
category: 'Performance',
|
||||
origin_project: 'aimo3',
|
||||
transferred_to: ['nemotron'],
|
||||
transfer_count: 1,
|
||||
last_used: '2026-04-11T08:00:00Z',
|
||||
confidence: 0.84
|
||||
},
|
||||
{
|
||||
name: 'Cross-encoder rerank at k=30',
|
||||
category: 'Performance',
|
||||
origin_project: 'vestige',
|
||||
transferred_to: ['nullgaze'],
|
||||
transfer_count: 1,
|
||||
last_used: '2026-04-14T17:55:00Z',
|
||||
confidence: 0.79
|
||||
},
|
||||
|
||||
// Security
|
||||
{
|
||||
name: 'Rotated auth token in env var',
|
||||
category: 'Security',
|
||||
origin_project: 'vestige',
|
||||
transferred_to: ['nullgaze', 'injeranet', 'nightvision'],
|
||||
transfer_count: 3,
|
||||
last_used: '2026-04-16T20:12:00Z',
|
||||
confidence: 0.96
|
||||
},
|
||||
{
|
||||
name: 'Parameterized SQL via rusqlite params!',
|
||||
category: 'Security',
|
||||
origin_project: 'vestige',
|
||||
transferred_to: ['nullgaze'],
|
||||
transfer_count: 1,
|
||||
last_used: '2026-04-10T13:40:00Z',
|
||||
confidence: 0.89
|
||||
},
|
||||
{
|
||||
name: '664-pattern secret scanner',
|
||||
category: 'Security',
|
||||
origin_project: 'nullgaze',
|
||||
transferred_to: ['vestige', 'nightvision', 'injeranet'],
|
||||
transfer_count: 3,
|
||||
last_used: '2026-04-20T05:30:00Z',
|
||||
confidence: 0.97
|
||||
},
|
||||
{
|
||||
name: 'CSP header with nonce-based script allow',
|
||||
category: 'Security',
|
||||
origin_project: 'nullgaze',
|
||||
transferred_to: ['nightvision'],
|
||||
transfer_count: 1,
|
||||
last_used: '2026-04-05T16:08:00Z',
|
||||
confidence: 0.8
|
||||
}
|
||||
];
|
||||
|
||||
return { projects, patterns };
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
// TODO: const res = await fetch('/api/patterns/cross-project');
|
||||
// data = await res.json();
|
||||
data = await mockFetchCrossProject();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load pattern transfers';
|
||||
data = { projects: [], patterns: [] };
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => load());
|
||||
|
||||
// Filter by active category first — this drives both the heatmap and sidebar.
|
||||
const categoryFiltered = $derived(
|
||||
activeCategory === 'All'
|
||||
? data.patterns
|
||||
: data.patterns.filter((p) => p.category === activeCategory)
|
||||
);
|
||||
|
||||
// Sidebar list: if a cell is selected, show only A → B; else show all (top-N
|
||||
// by transfer_count). Still respects active category via categoryFiltered.
|
||||
const sidebarPatterns = $derived.by(() => {
|
||||
const list = selectedCell
|
||||
? categoryFiltered.filter(
|
||||
(p) =>
|
||||
p.origin_project === selectedCell!.from &&
|
||||
p.transferred_to.includes(selectedCell!.to)
|
||||
)
|
||||
: categoryFiltered;
|
||||
return [...list].sort((a, b) => b.transfer_count - a.transfer_count);
|
||||
});
|
||||
|
||||
// Stats footer
|
||||
const totalTransfers = $derived(
|
||||
categoryFiltered.reduce((sum, p) => sum + p.transferred_to.length, 0)
|
||||
);
|
||||
const projectCount = $derived(data.projects.length);
|
||||
const patternCount = $derived(categoryFiltered.length);
|
||||
|
||||
function selectCategory(c: 'All' | Category) {
|
||||
activeCategory = c;
|
||||
selectedCell = null; // clear cell filter when switching category
|
||||
}
|
||||
|
||||
function onCellClick(from: string, to: string) {
|
||||
if (selectedCell && selectedCell.from === from && selectedCell.to === to) {
|
||||
selectedCell = null;
|
||||
} else {
|
||||
selectedCell = { from, to };
|
||||
}
|
||||
}
|
||||
|
||||
function clearCellFilter() {
|
||||
selectedCell = null;
|
||||
}
|
||||
|
||||
function relativeDate(iso: string): string {
|
||||
const then = new Date(iso).getTime();
|
||||
const now = Date.now();
|
||||
const days = Math.floor((now - then) / 86_400_000);
|
||||
if (days <= 0) return 'today';
|
||||
if (days === 1) return '1d ago';
|
||||
if (days < 30) return `${days}d ago`;
|
||||
const months = Math.floor(days / 30);
|
||||
return `${months}mo ago`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative mx-auto max-w-7xl space-y-6 p-6">
|
||||
<!-- Header -->
|
||||
<header class="space-y-2">
|
||||
<h1 class="text-xl font-semibold text-bright">Cross-Project Intelligence</h1>
|
||||
<p class="text-sm text-dim">Patterns learned here, applied there.</p>
|
||||
</header>
|
||||
|
||||
<!-- Category tabs -->
|
||||
<div class="glass-panel flex flex-wrap items-center gap-1.5 rounded-2xl p-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => selectCategory('All')}
|
||||
class="rounded-lg px-3 py-1.5 text-xs font-medium transition {activeCategory === 'All'
|
||||
? 'bg-synapse/25 text-synapse-glow'
|
||||
: 'text-dim hover:bg-white/[0.04] hover:text-text'}"
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{#each CATEGORIES as cat (cat)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => selectCategory(cat)}
|
||||
class="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium transition {activeCategory ===
|
||||
cat
|
||||
? 'bg-synapse/25 text-synapse-glow'
|
||||
: 'text-dim hover:bg-white/[0.04] hover:text-text'}"
|
||||
>
|
||||
<span
|
||||
class="h-1.5 w-1.5 rounded-full"
|
||||
style="background: {CATEGORY_COLORS[cat]}"
|
||||
></span>
|
||||
{cat}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="glass-panel flex flex-col items-center gap-3 rounded-2xl p-10 text-center">
|
||||
<div class="text-sm text-decay">Couldn't load pattern transfers</div>
|
||||
<div class="max-w-md text-xs text-muted">{error}</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={load}
|
||||
class="mt-2 rounded-lg bg-synapse/20 px-4 py-2 text-xs font-medium text-synapse-glow transition hover:bg-synapse/30"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
{:else if loading}
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_340px]">
|
||||
<div class="glass-subtle h-[520px] animate-pulse rounded-2xl"></div>
|
||||
<div class="glass-subtle h-[520px] animate-pulse rounded-2xl"></div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Main grid: heatmap (70%) + sidebar -->
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_340px]">
|
||||
<!-- Heatmap column -->
|
||||
<div class="space-y-4">
|
||||
<PatternTransferHeatmap
|
||||
projects={data.projects}
|
||||
patterns={categoryFiltered}
|
||||
{selectedCell}
|
||||
{onCellClick}
|
||||
/>
|
||||
|
||||
{#if selectedCell}
|
||||
<div
|
||||
class="glass-subtle flex items-center justify-between rounded-xl px-4 py-2.5 text-xs"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-muted">Filtered to</span>
|
||||
<span class="font-mono text-bright">{selectedCell.from}</span>
|
||||
<span class="text-synapse-glow">→</span>
|
||||
<span class="font-mono text-bright">{selectedCell.to}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={clearCellFilter}
|
||||
class="rounded-md bg-white/[0.04] px-2 py-1 text-dim transition hover:bg-white/[0.08] hover:text-text"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Sidebar: Top Transferred Patterns -->
|
||||
<aside class="glass-panel flex flex-col rounded-2xl p-4">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold text-bright">Top Transferred Patterns</h2>
|
||||
<span class="text-[11px] text-muted">
|
||||
{sidebarPatterns.length}
|
||||
{sidebarPatterns.length === 1 ? 'pattern' : 'patterns'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if sidebarPatterns.length === 0}
|
||||
<div class="flex flex-1 flex-col items-center justify-center gap-2 py-10 text-center">
|
||||
<div class="text-xs font-medium text-dim">No matching patterns</div>
|
||||
<div class="max-w-[220px] text-[11px] text-muted">
|
||||
{selectedCell
|
||||
? 'No patterns transferred from this origin to this destination.'
|
||||
: 'No patterns in this category.'}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="flex-1 space-y-2 overflow-y-auto pr-1" style="max-height: 560px;">
|
||||
{#each sidebarPatterns as p (p.name)}
|
||||
<li
|
||||
class="rounded-lg border border-synapse/5 bg-white/[0.02] p-3 transition hover:border-synapse/20 hover:bg-white/[0.04]"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0 flex-1 space-y-1.5">
|
||||
<div class="truncate text-xs font-medium text-bright" title={p.name}>
|
||||
{p.name}
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<span
|
||||
class="rounded-full border px-1.5 py-0.5 text-[10px] font-medium"
|
||||
style="border-color: {CATEGORY_COLORS[
|
||||
p.category
|
||||
]}66; color: {CATEGORY_COLORS[p.category]}; background: {CATEGORY_COLORS[
|
||||
p.category
|
||||
]}1a;"
|
||||
>
|
||||
{p.category}
|
||||
</span>
|
||||
<span class="text-[10px] text-muted">{relativeDate(p.last_used)}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 text-[11px] text-dim">
|
||||
<span class="font-mono text-text">{p.origin_project}</span>
|
||||
<span class="text-synapse-glow">→</span>
|
||||
<span class="text-muted">
|
||||
{p.transferred_to.length}
|
||||
{p.transferred_to.length === 1 ? 'project' : 'projects'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-shrink-0 flex-col items-end gap-1">
|
||||
<span
|
||||
class="rounded-full bg-synapse/15 px-2 py-0.5 text-xs font-semibold text-synapse-glow"
|
||||
>
|
||||
{p.transfer_count}
|
||||
</span>
|
||||
<span class="text-[10px] text-muted">
|
||||
{(p.confidence * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<!-- Stats footer -->
|
||||
<footer
|
||||
class="glass-subtle flex flex-wrap items-center justify-between gap-3 rounded-xl px-4 py-3 text-xs text-dim"
|
||||
>
|
||||
<div>
|
||||
<span class="font-semibold text-bright">{patternCount}</span>
|
||||
pattern{patternCount === 1 ? '' : 's'} across
|
||||
<span class="font-semibold text-bright">{projectCount}</span>
|
||||
project{projectCount === 1 ? '' : 's'},
|
||||
<span class="font-semibold text-bright">{totalTransfers}</span>
|
||||
total transfer{totalTransfers === 1 ? '' : 's'}
|
||||
</div>
|
||||
<div class="text-muted">
|
||||
{activeCategory === 'All' ? 'All categories' : activeCategory}
|
||||
</div>
|
||||
</footer>
|
||||
{/if}
|
||||
</div>
|
||||
668
apps/dashboard/src/routes/(app)/reasoning/+page.svelte
Normal file
668
apps/dashboard/src/routes/(app)/reasoning/+page.svelte
Normal file
|
|
@ -0,0 +1,668 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$stores/api';
|
||||
import ReasoningChain from '$components/ReasoningChain.svelte';
|
||||
import EvidenceCard from '$components/EvidenceCard.svelte';
|
||||
import {
|
||||
confidenceColor,
|
||||
confidenceLabel,
|
||||
} from '$components/reasoning-helpers';
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Local type — mirrors the shape deep_reference will return once
|
||||
// /api/deep-reference lands. See backend MCP tool `deep_reference`.
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
type Role = 'primary' | 'supporting' | 'contradicting' | 'superseded';
|
||||
|
||||
interface EvidenceEntry {
|
||||
id: string;
|
||||
trust: number; // 0-1
|
||||
date: string; // ISO
|
||||
role: Role;
|
||||
preview: string;
|
||||
nodeType?: string;
|
||||
}
|
||||
|
||||
interface RecommendedAnswer {
|
||||
answer_preview: string;
|
||||
memory_id: string;
|
||||
trust_score: number;
|
||||
date: string;
|
||||
}
|
||||
|
||||
interface ContradictionPair {
|
||||
a_id: string;
|
||||
b_id: string;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
interface SupersessionEntry {
|
||||
old_id: string;
|
||||
new_id: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
interface EvolutionPoint {
|
||||
date: string;
|
||||
summary: string;
|
||||
trust: number;
|
||||
}
|
||||
|
||||
interface DeepReferenceResponse {
|
||||
intent: string;
|
||||
reasoning: string;
|
||||
recommended: RecommendedAnswer;
|
||||
evidence: EvidenceEntry[];
|
||||
contradictions: ContradictionPair[];
|
||||
superseded: SupersessionEntry[];
|
||||
evolution: EvolutionPoint[];
|
||||
related_insights: string[];
|
||||
confidence: number; // 0-100
|
||||
memoriesAnalyzed: number;
|
||||
}
|
||||
|
||||
// Real backend call — wraps the 8-stage deep_reference cognitive pipeline
|
||||
// via /api/deep_reference. The handler emits DeepReferenceCompleted on
|
||||
// the WebSocket so Graph3D can glide + pulse + arc in the 3D scene.
|
||||
async function deepReferenceFetch(query: string): Promise<DeepReferenceResponse> {
|
||||
const raw = (await api.deepReference(query, 20)) as Record<string, unknown>;
|
||||
|
||||
const evidenceRaw = Array.isArray(raw.evidence) ? (raw.evidence as Record<string, unknown>[]) : [];
|
||||
const evidence: EvidenceEntry[] = evidenceRaw.map((e) => {
|
||||
const trustNum = typeof e.trust === 'number' ? (e.trust as number) : 0;
|
||||
// Backend trust is 0-1; if it came back > 1 treat as already-scaled percent.
|
||||
const trust = trustNum > 1 ? trustNum / 100 : trustNum;
|
||||
const role = (e.role as Role) || 'supporting';
|
||||
return {
|
||||
id: String(e.id ?? ''),
|
||||
trust: Math.max(0, Math.min(1, trust)),
|
||||
date: String(e.date ?? ''),
|
||||
role,
|
||||
preview: String(e.preview ?? ''),
|
||||
nodeType: e.node_type ? String(e.node_type) : (e.nodeType ? String(e.nodeType) : undefined)
|
||||
};
|
||||
});
|
||||
|
||||
const rec = raw.recommended as Record<string, unknown> | undefined;
|
||||
const recommended: RecommendedAnswer = {
|
||||
answer_preview: String(rec?.answer_preview ?? evidence[0]?.preview ?? ''),
|
||||
memory_id: String(rec?.memory_id ?? evidence[0]?.id ?? ''),
|
||||
trust_score: (() => {
|
||||
const t = rec?.trust_score;
|
||||
if (typeof t === 'number') return t > 1 ? t / 100 : t;
|
||||
return evidence[0]?.trust ?? 0;
|
||||
})(),
|
||||
date: String(rec?.date ?? evidence[0]?.date ?? '')
|
||||
};
|
||||
|
||||
const contradictionsRaw = Array.isArray(raw.contradictions)
|
||||
? (raw.contradictions as Record<string, unknown>[])
|
||||
: [];
|
||||
const contradictions: ContradictionPair[] = contradictionsRaw.map((c) => ({
|
||||
a_id: String(c.a_id ?? ''),
|
||||
b_id: String(c.b_id ?? ''),
|
||||
summary: String(c.summary ?? c.reason ?? 'Trust-weighted conflict between high-FSRS memories.')
|
||||
}));
|
||||
|
||||
const supersededRaw = Array.isArray(raw.superseded)
|
||||
? (raw.superseded as Record<string, unknown>[])
|
||||
: [];
|
||||
const superseded: SupersessionEntry[] = supersededRaw.map((s) => ({
|
||||
old_id: String(s.old_id ?? ''),
|
||||
new_id: String(s.new_id ?? recommended.memory_id ?? ''),
|
||||
reason: String(s.reason ?? 'Superseded by newer memory with higher FSRS trust.')
|
||||
}));
|
||||
|
||||
// Backend emits evolution with `preview`; UI type wants `summary`.
|
||||
// Normalise so the component can stay agnostic.
|
||||
const evolutionRaw = Array.isArray(raw.evolution)
|
||||
? (raw.evolution as Record<string, unknown>[])
|
||||
: [];
|
||||
const evolution: EvolutionPoint[] = evolutionRaw.map((p) => ({
|
||||
date: String(p.date ?? ''),
|
||||
summary: String(p.summary ?? p.preview ?? ''),
|
||||
trust: (() => {
|
||||
const t = p.trust;
|
||||
if (typeof t === 'number') return t > 1 ? t / 100 : t;
|
||||
return 0;
|
||||
})()
|
||||
}));
|
||||
|
||||
const related_insights = Array.isArray(raw.related_insights)
|
||||
? (raw.related_insights as string[])
|
||||
: [];
|
||||
|
||||
const confidenceRaw = typeof raw.confidence === 'number' ? (raw.confidence as number) : 0;
|
||||
// Backend already scales to 0-100 for dashboard consumers, but defend
|
||||
// against a change: treat 0-1 values as fractions.
|
||||
const confidence =
|
||||
confidenceRaw > 1 ? Math.round(confidenceRaw) : Math.round(confidenceRaw * 100);
|
||||
|
||||
const intent = String(raw.intent ?? 'Synthesis');
|
||||
const reasoning = String(raw.reasoning ?? raw.guidance ?? '');
|
||||
const memoriesAnalyzed =
|
||||
typeof raw.memoriesAnalyzed === 'number'
|
||||
? (raw.memoriesAnalyzed as number)
|
||||
: evidence.length;
|
||||
|
||||
return {
|
||||
intent,
|
||||
reasoning,
|
||||
recommended,
|
||||
evidence,
|
||||
contradictions,
|
||||
superseded,
|
||||
evolution,
|
||||
related_insights,
|
||||
confidence,
|
||||
memoriesAnalyzed
|
||||
};
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// State
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
let query = $state('');
|
||||
let loading = $state(false);
|
||||
let response: DeepReferenceResponse | null = $state(null);
|
||||
let error: string | null = $state(null);
|
||||
let askInputEl: HTMLInputElement | null = $state(null);
|
||||
|
||||
// Evidence DOM refs for SVG arc drawing between contradicting pairs
|
||||
let evidenceGridEl: HTMLDivElement | null = $state(null);
|
||||
let arcs: { x1: number; y1: number; x2: number; y2: number }[] = $state([]);
|
||||
|
||||
async function ask() {
|
||||
const q = query.trim();
|
||||
if (!q || loading) return;
|
||||
loading = true;
|
||||
error = null;
|
||||
response = null;
|
||||
arcs = [];
|
||||
try {
|
||||
response = await deepReferenceFetch(q);
|
||||
// After DOM paints the evidence cards, measure & draw arcs
|
||||
requestAnimationFrame(() => requestAnimationFrame(measureArcs));
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Unknown error';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function measureArcs() {
|
||||
if (!response || !evidenceGridEl || response.contradictions.length === 0) {
|
||||
arcs = [];
|
||||
return;
|
||||
}
|
||||
const gridRect = evidenceGridEl.getBoundingClientRect();
|
||||
const next: typeof arcs = [];
|
||||
for (const c of response.contradictions) {
|
||||
const a = evidenceGridEl.querySelector<HTMLElement>(`[data-evidence-id="${c.a_id}"]`);
|
||||
const b = evidenceGridEl.querySelector<HTMLElement>(`[data-evidence-id="${c.b_id}"]`);
|
||||
if (!a || !b) continue;
|
||||
const ar = a.getBoundingClientRect();
|
||||
const br = b.getBoundingClientRect();
|
||||
next.push({
|
||||
x1: ar.left - gridRect.left + ar.width / 2,
|
||||
y1: ar.top - gridRect.top + ar.height / 2,
|
||||
x2: br.left - gridRect.left + br.width / 2,
|
||||
y2: br.top - gridRect.top + br.height / 2
|
||||
});
|
||||
}
|
||||
arcs = next;
|
||||
}
|
||||
|
||||
function handleGlobalKey(e: KeyboardEvent) {
|
||||
// Cmd/Ctrl + K focuses the ask box
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
||||
e.preventDefault();
|
||||
askInputEl?.focus();
|
||||
askInputEl?.select();
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
askInputEl?.focus();
|
||||
window.addEventListener('keydown', handleGlobalKey);
|
||||
window.addEventListener('resize', measureArcs);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleGlobalKey);
|
||||
window.removeEventListener('resize', measureArcs);
|
||||
};
|
||||
});
|
||||
|
||||
const exampleQueries = [
|
||||
'What port does the dev server use?',
|
||||
'Should I enable prefix caching with vLLM?',
|
||||
'Why did the AIMO3 submission score 36/50?',
|
||||
'How does FSRS-6 trust scoring work?'
|
||||
];
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Reasoning Theater · Vestige</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="p-6 max-w-6xl mx-auto space-y-8">
|
||||
<!-- Header -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-2xl text-dream-glow">❖</span>
|
||||
<h1 class="text-xl text-bright font-semibold">Reasoning Theater</h1>
|
||||
<span class="px-2 py-0.5 rounded bg-dream/15 border border-dream/30 text-[10px] text-dream-glow uppercase tracking-wider">
|
||||
deep_reference
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-dim max-w-2xl">
|
||||
Watch Vestige reason. Your query runs the 8-stage cognitive pipeline — broad retrieval,
|
||||
spreading activation, FSRS trust scoring, intent classification, supersession, contradiction
|
||||
analysis, relation assessment, template reasoning — and returns a pre-built answer with
|
||||
trust-scored evidence.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Cmd+K Ask Palette -->
|
||||
<div class="glass-panel rounded-2xl p-5 space-y-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-lg text-synapse-glow">◎</span>
|
||||
<input
|
||||
bind:this={askInputEl}
|
||||
type="text"
|
||||
bind:value={query}
|
||||
onkeydown={(e) => e.key === 'Enter' && ask()}
|
||||
placeholder="Ask your memory anything..."
|
||||
class="flex-1 bg-transparent text-bright text-lg placeholder:text-muted focus:outline-none font-mono"
|
||||
/>
|
||||
<kbd class="hidden sm:inline-flex items-center gap-1 px-2 py-1 rounded bg-white/[0.04] border border-synapse/15 text-[10px] text-dim font-mono">
|
||||
<span>⌘</span>K
|
||||
</kbd>
|
||||
<button
|
||||
onclick={ask}
|
||||
disabled={!query.trim() || loading}
|
||||
class="px-4 py-2 rounded-xl bg-synapse/20 border border-synapse/40 text-synapse-glow text-sm hover:bg-synapse/30 transition disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? 'Reasoning…' : 'Reason'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if !response && !loading}
|
||||
<div class="flex flex-wrap gap-2 pt-1">
|
||||
<span class="text-[10px] uppercase tracking-wider text-muted mr-1 self-center">Try</span>
|
||||
{#each exampleQueries as ex}
|
||||
<button
|
||||
onclick={() => {
|
||||
query = ex;
|
||||
ask();
|
||||
}}
|
||||
class="px-2.5 py-1 rounded-full glass-subtle text-[11px] text-dim hover:text-synapse-glow hover:!border-synapse/30 transition"
|
||||
>
|
||||
{ex}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
{#if error}
|
||||
<div class="glass rounded-xl p-4 !border-decay/40 text-decay text-sm">
|
||||
<span class="font-medium">Error:</span>
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Loading state — chain runs alone -->
|
||||
{#if loading}
|
||||
<div class="glass-panel rounded-2xl p-6 space-y-4">
|
||||
<div class="flex items-center gap-2 text-xs text-dream-glow uppercase tracking-wider">
|
||||
<span class="animate-pulse-glow">●</span>
|
||||
<span>Running cognitive pipeline</span>
|
||||
</div>
|
||||
<ReasoningChain running />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Response -->
|
||||
{#if response && !loading}
|
||||
{@const conf = response.confidence}
|
||||
{@const confColor = confidenceColor(conf)}
|
||||
|
||||
<!-- REASONING CHAIN (hero — this IS the answer) -->
|
||||
{#if response.reasoning}
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-sm text-bright font-semibold flex items-center gap-2">
|
||||
<span class="text-dream-glow">❖</span>
|
||||
Reasoning
|
||||
</h2>
|
||||
<div class="flex items-center gap-3 text-[10px] text-muted font-mono">
|
||||
<span>intent: <span class="text-dim">{response.intent}</span></span>
|
||||
<span>·</span>
|
||||
<span>{response.memoriesAnalyzed} analyzed</span>
|
||||
<span>·</span>
|
||||
<span style="color: {confColor}">{conf}% {confidenceLabel(conf)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="glass-panel rounded-2xl p-6 font-mono text-sm text-bright whitespace-pre-wrap leading-relaxed"
|
||||
style="box-shadow: inset 0 1px 0 0 rgba(255,255,255,0.03), 0 0 32px {confColor}20, 0 8px 32px rgba(0,0,0,0.4); border-color: {confColor}35;"
|
||||
>{response.reasoning}</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Confidence meter + recommended answer (citation footer below the chain) -->
|
||||
<div class="grid md:grid-cols-[280px_1fr] gap-4">
|
||||
<!-- Confidence meter -->
|
||||
<div
|
||||
class="glass-panel rounded-2xl p-5 flex flex-col items-center justify-center text-center space-y-2"
|
||||
style="box-shadow: inset 0 1px 0 0 rgba(255,255,255,0.03), 0 0 32px {confColor}30, 0 8px 32px rgba(0,0,0,0.4); border-color: {confColor}40;"
|
||||
>
|
||||
<span class="text-[10px] uppercase tracking-wider text-dim">Confidence</span>
|
||||
<div class="relative">
|
||||
<span
|
||||
class="block text-6xl font-bold font-mono conf-number"
|
||||
style="color: {confColor}; text-shadow: 0 0 24px {confColor}80;"
|
||||
>
|
||||
{conf}<span class="text-2xl align-top opacity-60">%</span>
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
class="text-[10px] font-mono tracking-wider"
|
||||
style="color: {confColor}"
|
||||
>
|
||||
{confidenceLabel(conf)}
|
||||
</span>
|
||||
<!-- Confidence ring -->
|
||||
<svg width="220" height="14" viewBox="0 0 220 14" class="mt-1">
|
||||
<rect x="0" y="5" width="220" height="4" rx="2" fill="rgba(255,255,255,0.05)" />
|
||||
<rect
|
||||
x="0"
|
||||
y="5"
|
||||
width={(conf / 100) * 220}
|
||||
height="4"
|
||||
rx="2"
|
||||
fill={confColor}
|
||||
style="filter: drop-shadow(0 0 6px {confColor});"
|
||||
>
|
||||
<animate attributeName="width" from="0" to={(conf / 100) * 220} dur="0.9s" fill="freeze" />
|
||||
</rect>
|
||||
</svg>
|
||||
<div class="flex gap-3 pt-2 text-[10px] text-muted font-mono">
|
||||
<span>intent: <span class="text-dim">{response.intent}</span></span>
|
||||
<span>·</span>
|
||||
<span>{response.memoriesAnalyzed} analyzed</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recommended answer (primary source citation) -->
|
||||
<div class="glass-panel rounded-2xl p-5 space-y-3 !border-synapse/25">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-[10px] uppercase tracking-wider text-synapse-glow">Primary Source</span>
|
||||
<span class="text-[10px] font-mono text-muted" title={response.recommended.memory_id}>
|
||||
#{response.recommended.memory_id.slice(0, 8)}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-base text-bright leading-relaxed">{response.recommended.answer_preview}</p>
|
||||
<div class="flex items-center gap-4 text-[11px] text-muted pt-1 border-t border-synapse/10">
|
||||
<span class="flex items-center gap-1.5">
|
||||
<span class="w-2 h-2 rounded-full" style="background: {confidenceColor(response.recommended.trust_score * 100)}"></span>
|
||||
Trust {(response.recommended.trust_score * 100).toFixed(0)}%
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span>{new Date(response.recommended.date).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cognitive Pipeline visualization (how the engine got there) -->
|
||||
<div class="space-y-3">
|
||||
<h2 class="text-sm text-bright font-semibold flex items-center gap-2">
|
||||
<span class="text-dream-glow">⟿</span>
|
||||
Cognitive Pipeline
|
||||
</h2>
|
||||
<div class="glass-panel rounded-2xl p-5">
|
||||
<ReasoningChain
|
||||
intent={response.intent}
|
||||
memoriesAnalyzed={response.memoriesAnalyzed}
|
||||
evidenceCount={response.evidence.length}
|
||||
contradictionCount={response.contradictions.length}
|
||||
supersededCount={response.superseded.length}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Evidence grid -->
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-sm text-bright font-semibold flex items-center gap-2">
|
||||
<span class="text-synapse-glow">◈</span>
|
||||
Evidence
|
||||
<span class="text-muted font-normal">({response.evidence.length})</span>
|
||||
</h2>
|
||||
<div class="flex items-center gap-3 text-[10px] text-muted">
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="w-2 h-2 rounded-full bg-synapse-glow"></span>primary
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="w-2 h-2 rounded-full bg-recall"></span>supporting
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="w-2 h-2 rounded-full bg-decay"></span>contradicting
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="w-2 h-2 rounded-full bg-muted"></span>superseded
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div bind:this={evidenceGridEl} class="evidence-grid relative grid sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{#each response.evidence as ev, i (ev.id)}
|
||||
<EvidenceCard
|
||||
id={ev.id}
|
||||
trust={ev.trust}
|
||||
date={ev.date}
|
||||
role={ev.role}
|
||||
preview={ev.preview}
|
||||
nodeType={ev.nodeType}
|
||||
index={i}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
<!-- SVG overlay for contradiction arcs -->
|
||||
{#if arcs.length > 0}
|
||||
<svg class="contradiction-arcs pointer-events-none absolute inset-0 w-full h-full" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id="arcGrad" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#ef4444" stop-opacity="0.9" />
|
||||
<stop offset="50%" stop-color="#ef4444" stop-opacity="0.4" />
|
||||
<stop offset="100%" stop-color="#ef4444" stop-opacity="0.9" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{#each arcs as arc, i}
|
||||
{@const mx = (arc.x1 + arc.x2) / 2}
|
||||
{@const my = Math.min(arc.y1, arc.y2) - 28}
|
||||
<path
|
||||
d="M {arc.x1} {arc.y1} Q {mx} {my} {arc.x2} {arc.y2}"
|
||||
fill="none"
|
||||
stroke="url(#arcGrad)"
|
||||
stroke-width="1.5"
|
||||
stroke-dasharray="4 4"
|
||||
class="arc-path"
|
||||
style="animation-delay: {i * 120 + 600}ms;"
|
||||
/>
|
||||
<circle cx={arc.x1} cy={arc.y1} r="4" fill="#ef4444" opacity="0.8" class="arc-dot" style="animation-delay: {i * 120 + 600}ms;" />
|
||||
<circle cx={arc.x2} cy={arc.y2} r="4" fill="#ef4444" opacity="0.8" class="arc-dot" style="animation-delay: {i * 120 + 700}ms;" />
|
||||
{/each}
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contradictions section -->
|
||||
{#if response.contradictions.length > 0}
|
||||
<div class="space-y-3">
|
||||
<h2 class="text-sm font-semibold flex items-center gap-2" style="color: #fca5a5;">
|
||||
<span>⚡</span>
|
||||
Contradictions Detected
|
||||
<span class="font-normal text-muted">({response.contradictions.length})</span>
|
||||
</h2>
|
||||
<div class="glass rounded-2xl p-4 space-y-3 !border-decay/30">
|
||||
{#each response.contradictions as c, i}
|
||||
<div class="flex items-start gap-3 p-3 rounded-xl bg-decay/[0.05] border border-decay/20">
|
||||
<span class="text-decay text-lg">⚠</span>
|
||||
<div class="flex-1 space-y-1">
|
||||
<div class="flex items-center gap-2 text-[10px] font-mono text-muted">
|
||||
<span>#{c.a_id.slice(0, 8)}</span>
|
||||
<span class="text-decay">↔</span>
|
||||
<span>#{c.b_id.slice(0, 8)}</span>
|
||||
</div>
|
||||
<p class="text-sm text-text">{c.summary}</p>
|
||||
</div>
|
||||
<span class="text-[10px] font-mono text-muted">pair {i + 1}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Superseded -->
|
||||
{#if response.superseded.length > 0}
|
||||
<div class="space-y-3">
|
||||
<h2 class="text-sm text-dim font-semibold flex items-center gap-2">
|
||||
<span>⊘</span>
|
||||
Superseded
|
||||
<span class="font-normal text-muted">({response.superseded.length})</span>
|
||||
</h2>
|
||||
<div class="glass-subtle rounded-2xl p-4 space-y-2">
|
||||
{#each response.superseded as s}
|
||||
<div class="flex items-center gap-3 text-xs text-dim">
|
||||
<span class="font-mono text-muted">#{s.old_id.slice(0, 8)}</span>
|
||||
<span class="text-dream-glow">⟶</span>
|
||||
<span class="font-mono text-synapse-glow">#{s.new_id.slice(0, 8)}</span>
|
||||
<span class="text-muted">{s.reason}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Evolution + insights side-by-side -->
|
||||
<div class="grid md:grid-cols-2 gap-4">
|
||||
{#if response.evolution.length > 0}
|
||||
<div class="space-y-3">
|
||||
<h2 class="text-sm text-bright font-semibold flex items-center gap-2">
|
||||
<span class="text-dream-glow">↗</span>
|
||||
Evolution
|
||||
</h2>
|
||||
<div class="glass rounded-2xl p-4 space-y-2">
|
||||
{#each response.evolution as ev}
|
||||
<div class="flex items-start gap-3 text-xs">
|
||||
<span class="text-muted font-mono whitespace-nowrap">
|
||||
{new Date(ev.date).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}
|
||||
</span>
|
||||
<span
|
||||
class="mt-1 w-1.5 h-1.5 rounded-full flex-shrink-0"
|
||||
style="background: {confidenceColor(ev.trust * 100)}"
|
||||
></span>
|
||||
<span class="text-dim flex-1">{ev.summary}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if response.related_insights.length > 0}
|
||||
<div class="space-y-3">
|
||||
<h2 class="text-sm text-bright font-semibold flex items-center gap-2">
|
||||
<span class="text-dream-glow">◇</span>
|
||||
Related Insights
|
||||
</h2>
|
||||
<div class="glass rounded-2xl p-4 space-y-2">
|
||||
{#each response.related_insights as ins}
|
||||
<p class="text-xs text-dim leading-relaxed">
|
||||
<span class="text-synapse-glow mr-2">›</span>{ins}
|
||||
</p>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Empty state -->
|
||||
{#if !response && !loading && !error}
|
||||
<div class="glass-subtle rounded-2xl p-12 text-center space-y-3">
|
||||
<div class="text-5xl opacity-20">❖</div>
|
||||
<p class="text-sm text-dim">
|
||||
Ask anything. Vestige will run the full reasoning pipeline and show you its work.
|
||||
</p>
|
||||
<p class="text-[10px] text-muted font-mono">
|
||||
8-stage pipeline: retrieval → rerank → activation → trust-score → supersession →
|
||||
contradiction → relations → chain. Zero LLM calls, 100% local.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.conf-number {
|
||||
animation: conf-pop 900ms cubic-bezier(0.22, 0.8, 0.3, 1) backwards;
|
||||
}
|
||||
|
||||
@keyframes conf-pop {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.5);
|
||||
}
|
||||
60% {
|
||||
opacity: 1;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.arc-path {
|
||||
animation: arc-draw 900ms cubic-bezier(0.22, 0.8, 0.3, 1) backwards;
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
|
||||
@keyframes arc-draw {
|
||||
0% {
|
||||
opacity: 0;
|
||||
stroke-dasharray: 0 400;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
stroke-dasharray: 4 4;
|
||||
}
|
||||
}
|
||||
|
||||
.arc-dot {
|
||||
animation: arc-dot-pulse 1400ms ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes arc-dot-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.8;
|
||||
r: 4;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
r: 5;
|
||||
}
|
||||
}
|
||||
|
||||
.evidence-grid {
|
||||
/* give arc overlay room without affecting layout */
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.contradiction-arcs {
|
||||
z-index: 5;
|
||||
}
|
||||
</style>
|
||||
252
apps/dashboard/src/routes/(app)/schedule/+page.svelte
Normal file
252
apps/dashboard/src/routes/(app)/schedule/+page.svelte
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$stores/api';
|
||||
import type { Memory } from '$types';
|
||||
import FSRSCalendar from '$components/FSRSCalendar.svelte';
|
||||
import {
|
||||
classifyUrgency,
|
||||
computeScheduleStats,
|
||||
daysUntilReview,
|
||||
} from '$components/schedule-helpers';
|
||||
|
||||
type WindowFilter = 'today' | 'week' | 'month' | 'all';
|
||||
|
||||
let memories: Memory[] = $state([]);
|
||||
let totalMemories = $state(0);
|
||||
let loading = $state(true);
|
||||
let errored = $state(false);
|
||||
let windowFilter: WindowFilter = $state<WindowFilter>('week');
|
||||
|
||||
// The corpus cap. 2000 covers a very large personal corpus while keeping
|
||||
// the request fast; `truncated` surfaces when there's more to fetch.
|
||||
const FETCH_LIMIT = 2000;
|
||||
|
||||
async function fetchMemories() {
|
||||
const res = await api.memories.list({ limit: String(FETCH_LIMIT) });
|
||||
memories = res.memories;
|
||||
totalMemories = res.total;
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
await fetchMemories();
|
||||
} catch {
|
||||
errored = true;
|
||||
memories = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Only memories that actually have an FSRS next-review timestamp.
|
||||
let scheduled = $derived(memories.filter((m) => !!m.nextReviewAt));
|
||||
|
||||
let now = $derived(new Date());
|
||||
let truncated = $derived(totalMemories > memories.length);
|
||||
|
||||
// Memories that match the currently-selected window. The calendar itself
|
||||
// always renders the full 6-week window for spatial context — this filter
|
||||
// drives the sidebar counts and the right-hand list. Day-granular so the
|
||||
// buckets match the calendar cell colors (both go through classifyUrgency).
|
||||
let filtered = $derived(
|
||||
(() => {
|
||||
const wf: WindowFilter = windowFilter;
|
||||
if (wf === 'all') return scheduled;
|
||||
return scheduled.filter((m) => {
|
||||
const u = classifyUrgency(now, m.nextReviewAt);
|
||||
if (u === 'none') return false;
|
||||
if (wf === 'today') return u === 'overdue' || u === 'today';
|
||||
if (wf === 'week') return u !== 'future';
|
||||
// month: anything due within 30 whole days
|
||||
const d = daysUntilReview(now, m.nextReviewAt);
|
||||
return d !== null && d <= 30;
|
||||
});
|
||||
})()
|
||||
);
|
||||
|
||||
// Stats — due today, this week, this month — and avg days-until-review.
|
||||
let stats = $derived(computeScheduleStats(now, scheduled));
|
||||
|
||||
async function runConsolidation() {
|
||||
loading = true;
|
||||
try {
|
||||
await api.consolidate();
|
||||
await fetchMemories();
|
||||
errored = false;
|
||||
} catch {
|
||||
errored = true;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// The filter buttons.
|
||||
const FILTERS: { key: WindowFilter; label: string }[] = [
|
||||
{ key: 'today', label: 'Due today' },
|
||||
{ key: 'week', label: 'This week' },
|
||||
{ key: 'month', label: 'This month' },
|
||||
{ key: 'all', label: 'All upcoming' }
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="p-6 max-w-7xl mx-auto space-y-6">
|
||||
<div class="flex items-center justify-between flex-wrap gap-3">
|
||||
<div>
|
||||
<h1 class="text-xl text-bright font-semibold">Review Schedule</h1>
|
||||
<p class="text-xs text-dim mt-1">FSRS-6 next-review dates across your memory corpus</p>
|
||||
</div>
|
||||
<div class="flex gap-1 p-1 glass-subtle rounded-xl">
|
||||
{#each FILTERS as f}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (windowFilter = f.key)}
|
||||
class="px-3 py-1.5 text-xs rounded-lg transition-all
|
||||
{windowFilter === f.key
|
||||
? 'bg-synapse/20 text-synapse-glow border border-synapse/30'
|
||||
: 'text-dim hover:text-text hover:bg-white/[0.03] border border-transparent'}"
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if !loading && !errored && truncated}
|
||||
<div class="px-3 py-2 glass-subtle rounded-lg text-[11px] text-dim">
|
||||
Showing the first {memories.length.toLocaleString()} of {totalMemories.toLocaleString()} memories.
|
||||
Schedule reflects this slice only.
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<div class="grid lg:grid-cols-[1fr_280px] gap-6">
|
||||
<div class="space-y-3">
|
||||
<div class="h-14 glass-subtle rounded-xl animate-pulse"></div>
|
||||
<div class="grid grid-cols-7 gap-2">
|
||||
{#each Array(42) as _}
|
||||
<div class="aspect-square glass-subtle rounded-lg animate-pulse"></div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
{#each Array(5) as _}
|
||||
<div class="h-20 glass-subtle rounded-xl animate-pulse"></div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else if errored}
|
||||
<div class="p-10 glass rounded-xl text-center space-y-3">
|
||||
<p class="text-sm text-decay">API unavailable.</p>
|
||||
<p class="text-xs text-dim">Could not fetch memories from /api/memories.</p>
|
||||
</div>
|
||||
{:else if scheduled.length === 0}
|
||||
<div class="p-10 glass rounded-xl text-center space-y-4">
|
||||
<div class="text-4xl text-dream/40">◷</div>
|
||||
<p class="text-sm text-bright font-medium">FSRS review schedule not yet populated.</p>
|
||||
<p class="text-xs text-dim max-w-md mx-auto">
|
||||
None of your {memories.length} memor{memories.length === 1 ? 'y has' : 'ies have'} a
|
||||
<code class="text-muted">nextReviewAt</code> timestamp yet. Run consolidation to compute
|
||||
next-review dates via FSRS-6.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onclick={runConsolidation}
|
||||
class="px-4 py-2 bg-warning/20 border border-warning/40 text-warning text-sm rounded-xl hover:bg-warning/30 transition"
|
||||
>
|
||||
Run Consolidation
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid lg:grid-cols-[1fr_280px] gap-6">
|
||||
<!-- Calendar -->
|
||||
<div class="min-w-0">
|
||||
<FSRSCalendar memories={scheduled} />
|
||||
</div>
|
||||
|
||||
<!-- Sidebar: stats -->
|
||||
<aside class="space-y-4">
|
||||
<div class="p-5 glass rounded-xl space-y-4">
|
||||
<h2 class="text-xs text-dim font-semibold uppercase tracking-wider">Queue</h2>
|
||||
<div class="space-y-3">
|
||||
{#if stats.overdue > 0}
|
||||
<div class="flex items-baseline justify-between">
|
||||
<span class="text-xs text-dim">Overdue</span>
|
||||
<span class="text-2xl font-bold text-decay">{stats.overdue}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex items-baseline justify-between">
|
||||
<span class="text-xs text-dim">Due today</span>
|
||||
<span class="text-2xl font-bold text-warning">{stats.dueToday}</span>
|
||||
</div>
|
||||
<div class="flex items-baseline justify-between">
|
||||
<span class="text-xs text-dim">This week</span>
|
||||
<span class="text-2xl font-bold text-synapse-glow">{stats.dueThisWeek}</span>
|
||||
</div>
|
||||
<div class="flex items-baseline justify-between">
|
||||
<span class="text-xs text-dim">This month</span>
|
||||
<span class="text-2xl font-bold text-dream-glow">{stats.dueThisMonth}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pt-3 border-t border-synapse/10">
|
||||
<div class="flex items-baseline justify-between">
|
||||
<span class="text-xs text-dim">Avg days until review</span>
|
||||
<span class="text-lg font-semibold text-text">{stats.avgDays.toFixed(1)}</span>
|
||||
</div>
|
||||
<p class="text-[10px] text-muted mt-1">
|
||||
Across {scheduled.length} scheduled memor{scheduled.length === 1 ? 'y' : 'ies'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filtered list preview -->
|
||||
<div class="p-5 glass-subtle rounded-xl space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-xs text-dim font-semibold uppercase tracking-wider">
|
||||
{FILTERS.find((f) => f.key === windowFilter)?.label}
|
||||
</h2>
|
||||
<span class="text-xs text-muted">{filtered.length}</span>
|
||||
</div>
|
||||
{#if filtered.length === 0}
|
||||
<p class="text-xs text-muted italic">Nothing in this window.</p>
|
||||
{:else}
|
||||
<div class="space-y-2 max-h-96 overflow-y-auto pr-1">
|
||||
{#each filtered
|
||||
.slice()
|
||||
.sort((a, b) => (a.nextReviewAt ?? '').localeCompare(b.nextReviewAt ?? ''))
|
||||
.slice(0, 50) as m (m.id)}
|
||||
{@const urgency = classifyUrgency(now, m.nextReviewAt)}
|
||||
{@const delta = daysUntilReview(now, m.nextReviewAt) ?? 0}
|
||||
<div class="p-2 rounded-lg bg-white/[0.02] hover:bg-white/[0.04] transition">
|
||||
<p class="text-xs text-text leading-snug line-clamp-2">{m.content}</p>
|
||||
<div class="flex items-center gap-2 mt-1 text-[10px]">
|
||||
<span
|
||||
class="{urgency === 'overdue'
|
||||
? 'text-decay'
|
||||
: urgency === 'today'
|
||||
? 'text-warning'
|
||||
: urgency === 'week'
|
||||
? 'text-synapse-glow'
|
||||
: 'text-dream-glow'}"
|
||||
>
|
||||
{urgency === 'overdue'
|
||||
? `${-delta}d overdue`
|
||||
: urgency === 'today'
|
||||
? 'today'
|
||||
: `in ${delta}d`}
|
||||
</span>
|
||||
<span class="text-muted">· {(m.retentionStrength * 100).toFixed(0)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{#if filtered.length > 50}
|
||||
<p class="text-[10px] text-muted text-center pt-1">
|
||||
+{filtered.length - 50} more
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -1,7 +1,29 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$stores/api';
|
||||
import { isConnected, memoryCount, avgRetention } from '$stores/websocket';
|
||||
import { websocket, isConnected, memoryCount, avgRetention } from '$stores/websocket';
|
||||
import { fireDemoSequence } from '$stores/toast';
|
||||
|
||||
// v2.3 Birth Ritual demo — injects a synthetic MemoryCreated event so
|
||||
// Graph3D spawns a birth orb without needing a real ingest. Node types
|
||||
// cycle so back-to-back clicks show different colors. Pure dev/demo
|
||||
// affordance; production users see orbs fire on real ingests.
|
||||
const DEMO_NODE_TYPES = ['fact', 'concept', 'pattern', 'decision', 'person', 'place'];
|
||||
let birthCount = $state(0);
|
||||
function fireBirthRitualDemo() {
|
||||
const type = DEMO_NODE_TYPES[birthCount % DEMO_NODE_TYPES.length];
|
||||
birthCount++;
|
||||
websocket.injectEvent({
|
||||
type: 'MemoryCreated',
|
||||
data: {
|
||||
id: `demo-birth-${Date.now()}`,
|
||||
content: `Demo memory #${birthCount} — ${type}`,
|
||||
node_type: type,
|
||||
tags: ['demo', 'v2.3-birth-ritual'],
|
||||
retention: 0.9,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Operation states
|
||||
let consolidating = $state(false);
|
||||
|
|
@ -93,6 +115,34 @@
|
|||
<span class="text-dream">◈</span> Cognitive Operations
|
||||
</h2>
|
||||
|
||||
<!-- v2.2 Pulse — demo the InsightToast stream -->
|
||||
<div class="p-4 glass rounded-xl space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-sm text-text font-medium">Pulse Toast Preview</div>
|
||||
<div class="text-xs text-dim">Fire a synthetic event sequence — useful for UI demos</div>
|
||||
</div>
|
||||
<button onclick={fireDemoSequence}
|
||||
class="px-4 py-2 bg-synapse/20 border border-synapse/40 text-synapse-glow text-sm rounded-xl hover:bg-synapse/30 transition flex items-center gap-2">
|
||||
<span>✦</span> Preview Pulse
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- v2.3 Terrarium — demo the Memory Birth Ritual on the Graph page -->
|
||||
<div class="p-4 glass rounded-xl space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-sm text-text font-medium">Birth Ritual Preview</div>
|
||||
<div class="text-xs text-dim">Inject a synthetic memory — switch to Graph to watch the orb fly in</div>
|
||||
</div>
|
||||
<button onclick={fireBirthRitualDemo}
|
||||
class="px-4 py-2 bg-dream/20 border border-dream/40 text-dream-glow text-sm rounded-xl hover:bg-dream/30 transition flex items-center gap-2">
|
||||
<span>✺</span> Trigger Birth
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Consolidation -->
|
||||
<div class="p-4 glass rounded-xl space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@
|
|||
formatUptime,
|
||||
} from '$stores/websocket';
|
||||
import ForgettingIndicator from '$lib/components/ForgettingIndicator.svelte';
|
||||
import InsightToast from '$lib/components/InsightToast.svelte';
|
||||
import AmbientAwarenessStrip from '$lib/components/AmbientAwarenessStrip.svelte';
|
||||
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
|
||||
import { initTheme } from '$stores/theme';
|
||||
|
||||
let { children } = $props();
|
||||
let showCommandPalette = $state(false);
|
||||
|
|
@ -22,6 +26,7 @@
|
|||
|
||||
onMount(() => {
|
||||
websocket.connect();
|
||||
const teardownTheme = initTheme();
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||
|
|
@ -48,6 +53,9 @@
|
|||
const shortcutMap: Record<string, string> = {
|
||||
g: '/graph', m: '/memories', t: '/timeline', f: '/feed',
|
||||
e: '/explore', i: '/intentions', s: '/stats',
|
||||
r: '/reasoning', a: '/activation', d: '/dreams',
|
||||
c: '/schedule', p: '/importance', u: '/duplicates',
|
||||
x: '/contradictions', n: '/patterns',
|
||||
};
|
||||
const target = shortcutMap[e.key.toLowerCase()];
|
||||
if (target && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
||||
|
|
@ -60,15 +68,24 @@
|
|||
return () => {
|
||||
websocket.disconnect();
|
||||
window.removeEventListener('keydown', onKeyDown);
|
||||
teardownTheme();
|
||||
};
|
||||
});
|
||||
|
||||
const nav = [
|
||||
{ href: '/graph', label: 'Graph', icon: '◎', shortcut: 'G' },
|
||||
{ href: '/reasoning', label: 'Reasoning', icon: '✦', shortcut: 'R' },
|
||||
{ href: '/memories', label: 'Memories', icon: '◈', shortcut: 'M' },
|
||||
{ href: '/timeline', label: 'Timeline', icon: '◷', shortcut: 'T' },
|
||||
{ href: '/feed', label: 'Feed', icon: '◉', shortcut: 'F' },
|
||||
{ href: '/explore', label: 'Explore', icon: '◬', shortcut: 'E' },
|
||||
{ href: '/activation', label: 'Activation', icon: '◈', shortcut: 'A' },
|
||||
{ href: '/dreams', label: 'Dreams', icon: '✧', shortcut: 'D' },
|
||||
{ href: '/schedule', label: 'Schedule', icon: '◷', shortcut: 'C' },
|
||||
{ href: '/importance', label: 'Importance', icon: '◎', shortcut: 'P' },
|
||||
{ href: '/duplicates', label: 'Duplicates', icon: '◉', shortcut: 'U' },
|
||||
{ href: '/contradictions', label: 'Contradictions', icon: '⚠', shortcut: 'X' },
|
||||
{ href: '/patterns', label: 'Patterns', icon: '▦', shortcut: 'N' },
|
||||
{ href: '/intentions', label: 'Intentions', icon: '◇', shortcut: 'I' },
|
||||
{ href: '/stats', label: 'Stats', icon: '◫', shortcut: 'S' },
|
||||
{ href: '/settings', label: 'Settings', icon: '⚙', shortcut: ',' },
|
||||
|
|
@ -115,7 +132,7 @@
|
|||
</a>
|
||||
|
||||
<!-- Nav items -->
|
||||
<div class="flex-1 py-3 flex flex-col gap-1 px-2">
|
||||
<div class="flex-1 min-h-0 overflow-y-auto py-3 flex flex-col gap-1 px-2">
|
||||
{#each nav as item}
|
||||
{@const active = isActive(item.href, $page.url.pathname)}
|
||||
<a
|
||||
|
|
@ -148,6 +165,9 @@
|
|||
<div class="flex items-center gap-2 text-xs">
|
||||
<div class="w-2 h-2 rounded-full {$isConnected ? 'bg-recall animate-pulse-glow' : 'bg-decay'}"></div>
|
||||
<span class="hidden lg:block text-dim">{$isConnected ? 'Connected' : 'Offline'}</span>
|
||||
<div class="ml-auto">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
<div class="hidden lg:block text-xs text-muted space-y-0.5">
|
||||
<div>{$memoryCount} memories</div>
|
||||
|
|
@ -168,6 +188,7 @@
|
|||
|
||||
<!-- Main content -->
|
||||
<main class="flex-1 flex flex-col min-h-0 pb-16 md:pb-0">
|
||||
<AmbientAwarenessStrip />
|
||||
<div class="animate-page-in flex-1 min-h-0 overflow-y-auto">
|
||||
{@render children()}
|
||||
</div>
|
||||
|
|
@ -199,6 +220,9 @@
|
|||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- v2.2 Pulse — InsightToast overlay (floating, fixed) -->
|
||||
<InsightToast />
|
||||
|
||||
<!-- Command Palette overlay -->
|
||||
{#if showCommandPalette}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue