feat(graph): FSRS memory-state colour mode + legend overlay

Closes Agent 1's audit gap #4: FSRS memory state (Active / Dormant /
Silent / Unavailable) was computed server-side per query but never
rendered in the 3D graph. Spheres always tinted by node type.

The new colour mode adds a second channel that users can toggle
between at runtime — Type (default, existing behaviour) and State
(new). The toggle is a radio-pair pill in the graph page's top-right
control bar next to the node-count selector + Dream button.

Buckets + palette:
- Active    ≥ 70%  emerald #10b981  easily retrievable
- Dormant  40-70%  amber   #f59e0b  retrievable with effort
- Silent   10-40%  violet  #8b5cf6  difficult, needs cues
- Unavail.  < 10%  slate   #6b7280  needs reinforcement

Thresholds match `execute_system_status` at the backend so the graph
colour bands line up exactly with what the Stats page reports in its
stateDistribution block. Using retention as the proxy for the full
accessibility formula (retention × 0.5 + retrieval × 0.3 + storage ×
0.2) is an approximation — retention is the dominant 0.5 weight and
it is the only FSRS channel the current GraphNode DTO carries. Swap
to the full formula in a future release if the DTO grows.

Implementation:
- `apps/dashboard/src/lib/graph/nodes.ts` — new `MemoryState` type,
  `getMemoryState(retention)`, `MEMORY_STATE_COLORS`,
  `MEMORY_STATE_DESCRIPTIONS`, `ColorMode`, `getNodeColor(node, mode)`.
- `NodeManager.colorMode` field (default `'type'`). `createNodeMeshes`
  now calls `getNodeColor(node, this.colorMode)` so newly-added nodes
  during the session follow the toggled mode.
- New `NodeManager.setColorMode(mode)` mutates every live mesh's
  material + glow sprite colour in place. Idempotent; cheap. Does NOT
  touch opacity/emissive-intensity so the v2.0.5 suppression dimming
  layer keeps working unchanged.
- New `MemoryStateLegend.svelte` floating overlay in the bottom-right
  when state mode is active (hidden in type mode so the legend doesn't
  compete with the node-type palette).
- `Graph3D.svelte` accepts a new `colorMode` prop (default `'type'`)
  and runs a `$effect` that calls `setColorMode` on every toggle.
- Dashboard rebuild picks up the new component + wiring.

Tests: 171 vitest, svelte-check 581 files / 0 errors. No backend
changes; this is pure dashboard code.
This commit is contained in:
Sam Valladares 2026-04-19 20:45:08 -05:00
parent 40b963e15b
commit 4c2016596c
208 changed files with 464 additions and 253 deletions

View file

@ -2,6 +2,65 @@ import * as THREE from 'three';
import type { GraphNode } from '$types';
import { NODE_TYPE_COLORS } from '$types';
// ============================================================================
// v2.0.8: Memory state coloring (FSRS accessibility bucket)
// ============================================================================
//
// Every knowledge_node has an FSRS accessibility score computed from
// (retention × 0.5 + retrieval × 0.3 + storage × 0.2). That score gates which
// memories surface in search and drives the Active / Dormant / Silent /
// Unavailable lifecycle documented by Bjork & Bjork 1992 dual-strength model.
//
// The backend computes all three channels, but `GraphNode` only carries
// `retention` — which is already the dominant weight (0.5 of 1.0). Using
// retention alone as a proxy is a known approximation; the buckets line up
// with the same thresholds `execute_system_status` uses server-side, so the
// visual labelling matches what `/api/stats` reports in its
// `stateDistribution` block.
export type MemoryState = 'active' | 'dormant' | 'silent' | 'unavailable';
/// Map an FSRS retention score to its accessibility bucket.
///
/// Thresholds match `execute_system_status` at the backend so the 3D graph's
/// colours line up with the numbers reported by `/api/stats`.
export function getMemoryState(retention: number): MemoryState {
if (retention >= 0.7) return 'active';
if (retention >= 0.4) return 'dormant';
if (retention >= 0.1) return 'silent';
return 'unavailable';
}
/// FSRS state palette. Distinct from NODE_TYPE_COLORS so the two modes can
/// coexist in the UI without overloading a single colour channel.
export const MEMORY_STATE_COLORS: Record<MemoryState, string> = {
active: '#10b981', // emerald — easily retrievable
dormant: '#f59e0b', // amber — retrievable with effort
silent: '#8b5cf6', // violet — difficult, needs cues
unavailable: '#6b7280', // slate — needs reinforcement
};
export const MEMORY_STATE_DESCRIPTIONS: Record<MemoryState, string> = {
active: 'Easily retrievable (retention ≥ 70%)',
dormant: 'Retrievable with effort (4070%)',
silent: 'Difficult, needs cues (1040%)',
unavailable: 'Needs reinforcement (< 10%)',
};
/// Color mode controls whether node spheres are tinted by node type
/// (fact / concept / event / …) or by FSRS memory state.
/// Type mode is the long-standing default; state mode is the v2.0.8 addition.
export type ColorMode = 'type' | 'state';
/// Pick a hex colour for a node given the active colour mode.
/// Falls back to the grey `unavailable` tone if the node's type is unknown.
export function getNodeColor(node: GraphNode, mode: ColorMode): string {
if (mode === 'state') {
return MEMORY_STATE_COLORS[getMemoryState(node.retention)];
}
return NODE_TYPE_COLORS[node.type] || '#8B95A5';
}
// Shared radial-gradient texture used for every node's glow Sprite.
// Without a map, THREE.Sprite renders as a flat coloured plane — additive-
// blending + UnrealBloomPass then amplifies its square edges into the
@ -80,6 +139,9 @@ export class NodeManager {
labelSprites = new Map<string, THREE.Sprite>();
hoveredNode: string | null = null;
selectedNode: string | null = null;
/// v2.0.8: colour nodes by FSRS memory state (active/dormant/silent/unavailable)
/// instead of node type. Switched at runtime via `setColorMode`.
colorMode: ColorMode = 'type';
private materializingNodes: MaterializingNode[] = [];
private dissolvingNodes: DissolvingNode[] = [];
@ -89,6 +151,38 @@ export class NodeManager {
this.group = new THREE.Group();
}
/// Switch the active colour mode and re-tint every live node in place.
/// Safe to call mid-animation — the mesh + glow materials are mutable.
/// Suppressed nodes keep their 20% opacity / zero-emissive treatment
/// since that is a separate visual channel (v2.0.5 SIF).
setColorMode(mode: ColorMode) {
if (this.colorMode === mode) return;
this.colorMode = mode;
for (const [id, mesh] of this.meshMap) {
const retention = (mesh.userData.retention as number | undefined) ?? 0;
const type = (mesh.userData.type as string | undefined) ?? 'fact';
const stubNode = {
id,
label: '',
type,
retention,
tags: [],
createdAt: '',
updatedAt: '',
isCenter: false,
} as GraphNode;
const hex = getNodeColor(stubNode, mode);
const newColor = new THREE.Color(hex);
const mat = mesh.material as THREE.MeshStandardMaterial;
mat.color.copy(newColor);
mat.emissive.copy(newColor);
const glow = this.glowMap.get(id);
if (glow) {
(glow.material as THREE.SpriteMaterial).color.copy(newColor);
}
}
}
createNodes(nodes: GraphNode[]): Map<string, THREE.Vector3> {
const phi = (1 + Math.sqrt(5)) / 2;
const count = nodes.length;
@ -119,7 +213,9 @@ export class NodeManager {
private createNodeMeshes(node: GraphNode, pos: THREE.Vector3, initialScale: number) {
const size = 0.5 + node.retention * 2;
const color = NODE_TYPE_COLORS[node.type] || '#8B95A5';
// v2.0.8: respect the active colour mode. Newly-added nodes during the
// same session follow the mode toggled at the UI layer.
const color = getNodeColor(node, this.colorMode);
// v2.0.5 Active Forgetting: suppressed memories dim to 20% opacity
// and lose their emissive glow, mimicking inhibitory-control silencing.