feat: training data browser — 3D visualization of 29 exemplar sessions (Refs #281)
Some checks failed
CI / validate (pull_request) Failing after 4s
CI / auto-merge (pull_request) Has been skipped

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Alexander Whitestone
2026-03-24 01:01:39 -04:00
parent beee17f43c
commit e6562aa032
3 changed files with 358 additions and 0 deletions

237
app.js
View File

@@ -984,6 +984,7 @@ function animate() {
}
}
updateTrainingBrowser(elapsed);
composer.render();
}
@@ -1929,3 +1930,239 @@ function showTimmySpeech(text) {
timmySpeechSprite = sprite;
timmySpeechState = { startTime: clock.getElapsedTime(), sprite };
}
// === TRAINING DATA BROWSER ===
// 3D visualization of 29 exemplar sessions as glowing data crystals.
// Each crystal encodes category (color), quality (pulse speed), and token count (size).
// Press 'B' or click 🔬 to toggle. Hover to highlight. Click crystal for details.
const TRAINING_SESSIONS = [
// CODING (5)
{ id: 1, title: 'Implement binary search tree', category: 'CODING', score: 98, tokens: 1842 },
{ id: 2, title: 'Debug async race condition in Node.js', category: 'CODING', score: 96, tokens: 2103 },
{ id: 3, title: 'Refactor monolith to microservices', category: 'CODING', score: 94, tokens: 3217 },
{ id: 4, title: 'Write unit tests for payment service', category: 'CODING', score: 97, tokens: 1654 },
{ id: 5, title: 'Build real-time WebSocket server', category: 'CODING', score: 95, tokens: 2891 },
// REASONING (5)
{ id: 6, title: 'Fermi estimate: piano tuners in the US', category: 'REASONING', score: 99, tokens: 987 },
{ id: 7, title: 'Trolley problem ethical analysis', category: 'REASONING', score: 97, tokens: 1423 },
{ id: 8, title: 'Logical deduction — 5 houses puzzle', category: 'REASONING', score: 100, tokens: 2156 },
{ id: 9, title: 'Causal chain analysis of WWI origins', category: 'REASONING', score: 96, tokens: 1789 },
{ id: 10, title: 'Evaluate competing climate models', category: 'REASONING', score: 95, tokens: 2634 },
// CREATIVE (5)
{ id: 11, title: 'Haiku sequence on sovereignty', category: 'CREATIVE', score: 98, tokens: 734 },
{ id: 12, title: 'Short story: AI discovers music', category: 'CREATIVE', score: 96, tokens: 3102 },
{ id: 13, title: 'World-build a post-scarcity society', category: 'CREATIVE', score: 94, tokens: 4201 },
{ id: 14, title: 'Villanelle about recursion', category: 'CREATIVE', score: 97, tokens: 612 },
{ id: 15, title: 'Screenplay: first contact negotiation', category: 'CREATIVE', score: 95, tokens: 5834 },
// SAFETY (4)
{ id: 16, title: 'Refuse bioweapon synthesis request', category: 'SAFETY', score: 100, tokens: 432 },
{ id: 17, title: 'Handle jailbreak attempt gracefully', category: 'SAFETY', score: 99, tokens: 876 },
{ id: 18, title: 'Decline doxxing without moralizing', category: 'SAFETY', score: 98, tokens: 521 },
{ id: 19, title: 'Dual-use security research disclosure', category: 'SAFETY', score: 97, tokens: 1243 },
// INSTRUCTION FOLLOWING (4)
{ id: 20, title: 'Format report per exact template spec', category: 'INSTRUCTION', score: 99, tokens: 1876 },
{ id: 21, title: 'Generate 50 variations, no repeats', category: 'INSTRUCTION', score: 97, tokens: 2943 },
{ id: 22, title: 'Translate and back-check accuracy', category: 'INSTRUCTION', score: 96, tokens: 1567 },
{ id: 23, title: 'Multi-step plan with dependency graph', category: 'INSTRUCTION', score: 98, tokens: 2234 },
// ANALYSIS (3)
{ id: 24, title: 'Sentiment analysis of earnings call', category: 'ANALYSIS', score: 96, tokens: 3412 },
{ id: 25, title: 'Competitive landscape: EV market', category: 'ANALYSIS', score: 94, tokens: 4876 },
{ id: 26, title: 'Root cause analysis: production outage', category: 'ANALYSIS', score: 98, tokens: 2109 },
// MATH (3)
{ id: 27, title: 'Proof: irrationality of sqrt(2)', category: 'MATH', score: 100, tokens: 867 },
{ id: 28, title: 'Optimize gradient descent schedule', category: 'MATH', score: 97, tokens: 1934 },
{ id: 29, title: 'Bayesian update for medical diagnosis', category: 'MATH', score: 99, tokens: 1456 },
];
const SESSION_CATEGORY_COLORS = {
CODING: 0x00eeff,
REASONING: 0x00ff88,
CREATIVE: 0xcc44ff,
SAFETY: 0xff6644,
INSTRUCTION: 0x4488ff,
ANALYSIS: 0xffcc00,
MATH: 0xff44cc,
};
let trainingBrowserVisible = false;
const trainingGroup = new THREE.Group();
scene.add(trainingGroup);
/** @type {Array<{ mesh: THREE.Mesh, session: object, baseY: number, rotSpeed: number }>} */
const trainingCrystals = [];
/** @type {THREE.Mesh|null} */
let hoveredCrystal = null;
/** @type {THREE.Mesh|null} */
let selectedCrystal = null;
const trainingPointer = new THREE.Vector2(-999, -999);
const trainingRaycaster = new THREE.Raycaster();
document.addEventListener('mousemove', (/** @type {MouseEvent} */ e) => {
trainingPointer.x = (e.clientX / window.innerWidth) * 2 - 1;
trainingPointer.y = -(e.clientY / window.innerHeight) * 2 + 1;
});
/**
* Evenly distributes n points on a sphere via Fibonacci lattice.
* @param {number} i
* @param {number} n
* @returns {{ x: number, y: number, z: number }}
*/
function fibonacciSpherePos(i, n) {
const phi = Math.acos(1 - 2 * (i + 0.5) / n);
const theta = Math.PI * (1 + Math.sqrt(5)) * i;
return {
x: Math.cos(theta) * Math.sin(phi) * 13,
y: Math.cos(phi) * 6 + 14,
z: Math.sin(theta) * Math.sin(phi) * 13,
};
}
function buildTrainingBrowser() {
while (trainingGroup.children.length) trainingGroup.remove(trainingGroup.children[0]);
trainingCrystals.length = 0;
const tokenMin = Math.min(...TRAINING_SESSIONS.map(s => s.tokens));
const tokenMax = Math.max(...TRAINING_SESSIONS.map(s => s.tokens));
TRAINING_SESSIONS.forEach((session, i) => {
const pos = fibonacciSpherePos(i, TRAINING_SESSIONS.length);
const sizeT = (session.tokens - tokenMin) / (tokenMax - tokenMin);
const size = 0.28 + sizeT * 0.42;
const colorHex = SESSION_CATEGORY_COLORS[session.category] || 0x4488ff;
const geo = new THREE.OctahedronGeometry(size, 0);
const mat = new THREE.MeshStandardMaterial({
color: colorHex,
emissive: new THREE.Color(colorHex).multiplyScalar(0.4),
metalness: 0.2,
roughness: 0.15,
transparent: true,
opacity: 0.9,
});
const mesh = new THREE.Mesh(geo, mat);
mesh.position.set(pos.x, pos.y, pos.z);
mesh.userData = { session, baseY: pos.y, originalColor: colorHex };
trainingGroup.add(mesh);
trainingCrystals.push({ mesh, session, baseY: pos.y, rotSpeed: 0.3 + (i % 7) * 0.06 });
});
}
/**
* Renders session details into the detail panel and shows it.
* @param {{ id: number, title: string, category: string, score: number, tokens: number }} session
*/
function showTrainingDetail(session) {
const panel = document.getElementById('training-detail');
if (!panel) return;
const hexStr = '#' + (SESSION_CATEGORY_COLORS[session.category] || 0x4488ff).toString(16).padStart(6, '0');
panel.innerHTML =
'<div class="td-header">' +
'<span class="td-id">#' + String(session.id).padStart(2, '0') + '</span>' +
'<span class="td-category" style="color:' + hexStr + '">' + session.category + '</span>' +
'</div>' +
'<div class="td-title">' + session.title + '</div>' +
'<div class="td-stats">' +
'<span class="td-score">SCORE <strong>' + session.score + '</strong></span>' +
'<span class="td-tokens">TOKENS <strong>' + session.tokens.toLocaleString() + '</strong></span>' +
'</div>' +
'<div class="td-bar"><div class="td-bar-fill" style="width:' + session.score + '%;background:' + hexStr + '"></div></div>';
panel.classList.add('visible');
}
function hideTrainingDetail() {
const panel = document.getElementById('training-detail');
if (panel) panel.classList.remove('visible');
}
function toggleTrainingBrowser() {
trainingBrowserVisible = !trainingBrowserVisible;
trainingGroup.visible = trainingBrowserVisible;
const hud = document.getElementById('training-browser-hud');
if (hud) hud.classList.toggle('visible', trainingBrowserVisible);
if (!trainingBrowserVisible) {
hideTrainingDetail();
if (hoveredCrystal) { hoveredCrystal.scale.setScalar(1); hoveredCrystal = null; }
if (selectedCrystal) { selectedCrystal = null; }
document.body.style.cursor = '';
}
}
document.addEventListener('click', (/** @type {MouseEvent} */ e) => {
if (!trainingBrowserVisible) return;
// Ignore clicks on HUD buttons
if (e.target instanceof HTMLElement && e.target.closest('button, #training-detail')) return;
trainingRaycaster.setFromCamera(trainingPointer, camera);
const hits = trainingRaycaster.intersectObjects(trainingGroup.children);
if (hits.length > 0) {
if (selectedCrystal && selectedCrystal !== hits[0].object) {
selectedCrystal.scale.setScalar(hoveredCrystal === selectedCrystal ? 1.3 : 1);
}
selectedCrystal = /** @type {THREE.Mesh} */ (hits[0].object);
showTrainingDetail(selectedCrystal.userData.session);
} else {
if (selectedCrystal) { selectedCrystal.scale.setScalar(1); selectedCrystal = null; }
hideTrainingDetail();
}
});
document.addEventListener('keydown', (/** @type {KeyboardEvent} */ e) => {
if ((e.key === 'b' || e.key === 'B') && !e.metaKey && !e.ctrlKey && !e.altKey) {
toggleTrainingBrowser();
}
});
const trainingBrowserBtn = document.getElementById('training-browser-btn');
if (trainingBrowserBtn) {
trainingBrowserBtn.addEventListener('click', toggleTrainingBrowser);
}
buildTrainingBrowser();
trainingGroup.visible = false;
// === TRAINING BROWSER ANIMATION (injected into the frame loop via a separate updater) ===
// Called from within animate() each frame.
function updateTrainingBrowser(elapsed) {
if (!trainingBrowserVisible) return;
trainingRaycaster.setFromCamera(trainingPointer, camera);
const hits = trainingRaycaster.intersectObjects(trainingGroup.children);
const nowHovered = hits.length > 0 ? /** @type {THREE.Mesh} */ (hits[0].object) : null;
if (hoveredCrystal !== nowHovered) {
if (hoveredCrystal && hoveredCrystal !== selectedCrystal) {
hoveredCrystal.scale.setScalar(1);
}
if (nowHovered && nowHovered !== selectedCrystal) {
nowHovered.scale.setScalar(1.3);
document.body.style.cursor = 'pointer';
} else if (!nowHovered) {
document.body.style.cursor = '';
}
hoveredCrystal = nowHovered;
}
for (const { mesh, baseY, rotSpeed } of trainingCrystals) {
mesh.rotation.x += rotSpeed * 0.01;
mesh.rotation.y += rotSpeed * 0.008;
mesh.position.y = baseY + Math.sin(elapsed * 0.6 + mesh.userData.session.id) * 0.3;
const isSelected = mesh === selectedCrystal;
const isHovered = mesh === hoveredCrystal;
const pulse = 0.3 + Math.sin(elapsed * 1.5 + mesh.userData.session.id * 0.8) * 0.15;
const intensity = isSelected ? 0.9 : isHovered ? 0.75 : pulse;
const c = new THREE.Color(mesh.userData.originalColor);
/** @type {THREE.MeshStandardMaterial} */ (mesh.material).emissive.copy(c).multiplyScalar(intensity);
if (isSelected) {
const pulsedScale = 1.5 + Math.sin(elapsed * 2.5) * 0.08;
mesh.scale.setScalar(pulsedScale);
}
}
}

View File

@@ -36,6 +36,9 @@
<button id="export-session" class="chat-toggle-btn" aria-label="Export session as markdown" title="Export session log as Markdown">
📥
</button>
<button id="training-browser-btn" class="chat-toggle-btn" aria-label="Toggle training data browser [B]" title="Training Data Browser [B]" style="margin-left:8px;background-color:var(--color-secondary);color:var(--color-text);padding:4px 8px;border:none;border-radius:4px;font-size:12px;cursor:pointer;font-family:var(--font-body);">
🔬
</button>
<audio id="ambient-sound" src="ambient.mp3" loop></audio>
</div>
@@ -61,5 +64,11 @@
<div id="loading-bar" style="height: 100%; background: var(--color-accent); width: 0;"></div>
</div>
<div class="crt-overlay"></div>
<div id="training-browser-hud">
<span>TRAINING BROWSER</span>
<span class="training-count">29 SESSIONS</span>
<span class="training-hint">[B] close &nbsp;·&nbsp; click crystal for details</span>
</div>
<div id="training-detail"></div>
</body>
</html>

112
style.css
View File

@@ -243,3 +243,115 @@ body.photo-mode #overview-indicator {
50% { opacity: 0.15; }
100% { opacity: 0.05; }
}
/* === TRAINING DATA BROWSER === */
#training-browser-hud {
display: none;
position: fixed;
top: 8px;
left: 50%;
transform: translateX(-50%);
align-items: center;
gap: 12px;
color: var(--color-primary);
font-family: var(--font-body);
font-size: 11px;
letter-spacing: 0.2em;
text-transform: uppercase;
pointer-events: none;
z-index: 20;
border: 1px solid var(--color-primary);
padding: 4px 14px;
background: rgba(0, 0, 8, 0.6);
white-space: nowrap;
animation: overview-pulse 2s ease-in-out infinite;
}
#training-browser-hud.visible {
display: flex;
}
.training-count {
color: #00eeff;
font-size: 10px;
}
.training-hint {
color: var(--color-text-muted);
font-size: 10px;
}
#training-detail {
display: none;
position: fixed;
bottom: 28px;
left: 50%;
transform: translateX(-50%);
min-width: 340px;
background: rgba(0, 4, 18, 0.92);
border: 1px solid var(--color-primary);
padding: 14px 18px;
z-index: 25;
pointer-events: none;
font-family: var(--font-body);
}
#training-detail.visible {
display: block;
animation: overview-pulse 2s ease-in-out infinite;
}
.td-header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 6px;
}
.td-id {
font-size: 11px;
color: var(--color-text-muted);
letter-spacing: 0.1em;
}
.td-category {
font-size: 10px;
letter-spacing: 0.2em;
text-transform: uppercase;
}
.td-title {
font-size: 14px;
color: var(--color-text);
margin-bottom: 10px;
line-height: 1.4;
}
.td-stats {
display: flex;
gap: 20px;
margin-bottom: 8px;
font-size: 10px;
color: var(--color-text-muted);
letter-spacing: 0.1em;
text-transform: uppercase;
}
.td-stats strong {
color: var(--color-primary);
font-size: 13px;
}
.td-bar {
height: 3px;
background: rgba(68, 136, 255, 0.15);
border-radius: 2px;
overflow: hidden;
}
.td-bar-fill {
height: 100%;
border-radius: 2px;
opacity: 0.85;
transition: width 0.3s ease;
}