feat: Enhance dashboard with charts, study report viewer, and pruning tracking

- Add ConvergencePlot component with running best, statistics, gradient fill
- Add ParameterImportanceChart with Pearson correlation analysis
- Add StudyReportViewer with KaTeX math rendering and full markdown support
- Update pruning endpoint to query Optuna database directly
- Add /report endpoint for STUDY_REPORT.md files
- Fix chart data transformation for single/multi-objective studies
- Update Protocol 13 documentation with new components
- Update generate-report skill with dashboard integration

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Antoine
2025-12-02 22:01:49 -05:00
parent ec5e42d733
commit 75d7036193
10 changed files with 2917 additions and 66 deletions

View File

@@ -0,0 +1,256 @@
/**
* Convergence Plot
* Shows optimization progress over time with running best and improvement rate
*/
import { useMemo } from 'react';
import {
LineChart,
Line,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
ReferenceLine,
Area,
ComposedChart,
Legend
} from 'recharts';
interface Trial {
trial_number: number;
values: number[];
state?: string;
}
interface ConvergencePlotProps {
trials: Trial[];
objectiveIndex?: number;
objectiveName?: string;
direction?: 'minimize' | 'maximize';
}
export function ConvergencePlot({
trials,
objectiveIndex = 0,
objectiveName = 'Objective',
direction = 'minimize'
}: ConvergencePlotProps) {
const convergenceData = useMemo(() => {
if (!trials || trials.length === 0) return [];
// Sort by trial number
const sortedTrials = [...trials]
.filter(t => t.values && t.values.length > objectiveIndex && t.state !== 'FAIL')
.sort((a, b) => a.trial_number - b.trial_number);
if (sortedTrials.length === 0) return [];
let runningBest = direction === 'minimize' ? Infinity : -Infinity;
const data = sortedTrials.map((trial, idx) => {
const value = trial.values[objectiveIndex];
// Update running best
if (direction === 'minimize') {
runningBest = Math.min(runningBest, value);
} else {
runningBest = Math.max(runningBest, value);
}
// Calculate improvement from first trial
const firstValue = sortedTrials[0].values[objectiveIndex];
const improvement = direction === 'minimize'
? ((firstValue - runningBest) / firstValue) * 100
: ((runningBest - firstValue) / firstValue) * 100;
return {
trial: trial.trial_number,
value,
best: runningBest,
improvement: Math.max(0, improvement),
index: idx + 1
};
});
return data;
}, [trials, objectiveIndex, direction]);
// Calculate statistics
const stats = useMemo(() => {
if (convergenceData.length === 0) return null;
const values = convergenceData.map(d => d.value);
const best = convergenceData[convergenceData.length - 1]?.best ?? 0;
const first = convergenceData[0]?.value ?? 0;
const improvement = direction === 'minimize'
? ((first - best) / first) * 100
: ((best - first) / first) * 100;
// Calculate when we found 90% of the improvement
const targetImprovement = 0.9 * improvement;
let trialsTo90 = convergenceData.length;
for (let i = 0; i < convergenceData.length; i++) {
if (convergenceData[i].improvement >= targetImprovement) {
trialsTo90 = i + 1;
break;
}
}
return {
best,
first,
improvement: Math.max(0, improvement),
totalTrials: convergenceData.length,
trialsTo90,
mean: values.reduce((a, b) => a + b, 0) / values.length,
std: Math.sqrt(values.map(v => Math.pow(v - values.reduce((a, b) => a + b, 0) / values.length, 2)).reduce((a, b) => a + b, 0) / values.length)
};
}, [convergenceData, direction]);
if (convergenceData.length === 0) {
return (
<div className="bg-dark-700 rounded-lg p-6 border border-dark-500 shadow-sm">
<h3 className="text-lg font-semibold mb-4 text-dark-100">Convergence Plot</h3>
<div className="h-64 flex items-center justify-center text-dark-400">
No trial data available
</div>
</div>
);
}
return (
<div className="bg-dark-700 rounded-lg p-6 border border-dark-500 shadow-sm">
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="text-lg font-semibold text-dark-100">Convergence Plot</h3>
<p className="text-sm text-dark-400 mt-1">
{objectiveName} over {convergenceData.length} trials
</p>
</div>
{stats && (
<div className="flex gap-6 text-sm">
<div className="text-right">
<div className="text-dark-400">Best</div>
<div className="font-semibold text-green-400">{stats.best.toFixed(4)}</div>
</div>
<div className="text-right">
<div className="text-dark-400">Improvement</div>
<div className="font-semibold text-blue-400">{stats.improvement.toFixed(1)}%</div>
</div>
<div className="text-right">
<div className="text-dark-400">90% at trial</div>
<div className="font-semibold text-purple-400">#{stats.trialsTo90}</div>
</div>
</div>
)}
</div>
<div className="h-72">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={convergenceData} margin={{ top: 10, right: 30, left: 10, bottom: 10 }}>
<defs>
<linearGradient id="colorValue" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#60a5fa" stopOpacity={0.3} />
<stop offset="95%" stopColor="#60a5fa" stopOpacity={0} />
</linearGradient>
</defs>
<XAxis
dataKey="trial"
tick={{ fill: '#94a3b8', fontSize: 11 }}
stroke="#334155"
label={{ value: 'Trial', position: 'bottom', offset: -5, fill: '#94a3b8', fontSize: 12 }}
/>
<YAxis
tick={{ fill: '#94a3b8', fontSize: 11 }}
stroke="#334155"
tickFormatter={(v) => v.toFixed(2)}
label={{ value: objectiveName, angle: -90, position: 'insideLeft', fill: '#94a3b8', fontSize: 12 }}
/>
<Tooltip
contentStyle={{
backgroundColor: '#1e293b',
border: '1px solid #334155',
borderRadius: '8px',
boxShadow: '0 4px 6px rgba(0,0,0,0.3)'
}}
labelStyle={{ color: '#e2e8f0' }}
formatter={(value: number, name: string) => {
const label = name === 'value' ? 'Trial Value' :
name === 'best' ? 'Running Best' : name;
return [value.toFixed(4), label];
}}
labelFormatter={(label) => `Trial #${label}`}
/>
<Legend
verticalAlign="top"
height={36}
wrapperStyle={{ color: '#94a3b8' }}
formatter={(value) => value === 'value' ? 'Trial Value' : 'Running Best'}
/>
{/* Area under trial values */}
<Area
type="monotone"
dataKey="value"
stroke="#60a5fa"
fill="url(#colorValue)"
strokeWidth={0}
/>
{/* Trial values as scatter points */}
<Line
type="monotone"
dataKey="value"
stroke="#60a5fa"
strokeWidth={1}
dot={{ fill: '#60a5fa', r: 3 }}
activeDot={{ fill: '#93c5fd', r: 5 }}
/>
{/* Running best line */}
<Line
type="stepAfter"
dataKey="best"
stroke="#10b981"
strokeWidth={2.5}
dot={false}
/>
{/* Reference line at best value */}
{stats && (
<ReferenceLine
y={stats.best}
stroke="#10b981"
strokeDasharray="5 5"
strokeOpacity={0.5}
/>
)}
</ComposedChart>
</ResponsiveContainer>
</div>
{/* Summary stats bar */}
{stats && (
<div className="grid grid-cols-4 gap-4 mt-4 pt-4 border-t border-dark-500 text-center text-sm">
<div>
<div className="text-dark-400">First Value</div>
<div className="font-medium text-dark-100">{stats.first.toFixed(4)}</div>
</div>
<div>
<div className="text-dark-400">Mean</div>
<div className="font-medium text-dark-100">{stats.mean.toFixed(4)}</div>
</div>
<div>
<div className="text-dark-400">Std Dev</div>
<div className="font-medium text-dark-100">{stats.std.toFixed(4)}</div>
</div>
<div>
<div className="text-dark-400">Total Trials</div>
<div className="font-medium text-dark-100">{stats.totalTrials}</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,184 @@
/**
* Parameter Importance Chart
* Shows which design parameters have the most impact on objectives
* Uses correlation analysis between parameters and objective values
*/
import { useMemo } from 'react';
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from 'recharts';
interface Trial {
trial_number: number;
values: number[];
params: Record<string, number>;
}
interface DesignVariable {
name: string;
parameter?: string;
unit?: string;
}
interface ParameterImportanceChartProps {
trials: Trial[];
designVariables: DesignVariable[];
objectiveIndex?: number;
objectiveName?: string;
}
// Calculate Pearson correlation coefficient
function pearsonCorrelation(x: number[], y: number[]): number {
if (x.length !== y.length || x.length < 2) return 0;
const n = x.length;
const sumX = x.reduce((a, b) => a + b, 0);
const sumY = y.reduce((a, b) => a + b, 0);
const sumXY = x.reduce((acc, xi, i) => acc + xi * y[i], 0);
const sumX2 = x.reduce((acc, xi) => acc + xi * xi, 0);
const sumY2 = y.reduce((acc, yi) => acc + yi * yi, 0);
const numerator = n * sumXY - sumX * sumY;
const denominator = Math.sqrt((n * sumX2 - sumX * sumX) * (n * sumY2 - sumY * sumY));
if (denominator === 0) return 0;
return numerator / denominator;
}
export function ParameterImportanceChart({
trials,
designVariables,
objectiveIndex = 0,
objectiveName = 'Objective'
}: ParameterImportanceChartProps) {
const importanceData = useMemo(() => {
if (!trials || trials.length < 3 || !designVariables || designVariables.length === 0) {
return [];
}
// Extract objective values
const objectiveValues = trials
.filter(t => t.values && t.values.length > objectiveIndex)
.map(t => t.values[objectiveIndex]);
if (objectiveValues.length < 3) return [];
// Calculate correlation for each parameter
const importances = designVariables.map(dv => {
const paramName = dv.parameter || dv.name;
const paramValues = trials
.filter(t => t.params && paramName in t.params && t.values && t.values.length > objectiveIndex)
.map(t => t.params[paramName]);
const corrObjectiveValues = trials
.filter(t => t.params && paramName in t.params && t.values && t.values.length > objectiveIndex)
.map(t => t.values[objectiveIndex]);
if (paramValues.length < 3) return { name: dv.name, importance: 0, correlation: 0 };
const correlation = pearsonCorrelation(paramValues, corrObjectiveValues);
// Use absolute correlation as importance (sign indicates direction)
const importance = Math.abs(correlation);
return {
name: dv.name,
importance: importance * 100, // Convert to percentage
correlation,
direction: correlation > 0 ? 'positive' : 'negative'
};
});
// Sort by importance (descending)
return importances.sort((a, b) => b.importance - a.importance);
}, [trials, designVariables, objectiveIndex]);
if (importanceData.length === 0) {
return (
<div className="bg-dark-700 rounded-lg p-6 border border-dark-500 shadow-sm">
<h3 className="text-lg font-semibold mb-4 text-dark-100">Parameter Importance</h3>
<div className="h-64 flex items-center justify-center text-dark-400">
Need at least 3 trials for correlation analysis
</div>
</div>
);
}
// Color based on correlation direction
const getBarColor = (entry: typeof importanceData[0]) => {
if (entry.correlation > 0) {
// Positive correlation (increasing param increases objective) - red for minimization
return '#f87171';
} else {
// Negative correlation (increasing param decreases objective) - green for minimization
return '#34d399';
}
};
return (
<div className="bg-dark-700 rounded-lg p-6 border border-dark-500 shadow-sm">
<div className="mb-4">
<h3 className="text-lg font-semibold text-dark-100">Parameter Importance</h3>
<p className="text-sm text-dark-400 mt-1">
Correlation with {objectiveName} ({trials.length} trials)
</p>
</div>
<div className="h-72">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={importanceData}
layout="vertical"
margin={{ top: 5, right: 30, left: 120, bottom: 5 }}
>
<XAxis
type="number"
domain={[0, 100]}
tickFormatter={(v) => `${v.toFixed(0)}%`}
tick={{ fill: '#94a3b8', fontSize: 11 }}
stroke="#334155"
/>
<YAxis
type="category"
dataKey="name"
width={110}
tick={{ fill: '#94a3b8', fontSize: 11 }}
stroke="#334155"
/>
<Tooltip
formatter={(value: number, _name: string, props: any) => {
const corr = props.payload.correlation;
return [
`${value.toFixed(1)}% (r=${corr.toFixed(3)})`,
'Importance'
];
}}
contentStyle={{
backgroundColor: '#1e293b',
border: '1px solid #334155',
borderRadius: '8px',
boxShadow: '0 4px 6px rgba(0,0,0,0.3)'
}}
labelStyle={{ color: '#e2e8f0' }}
/>
<Bar dataKey="importance" radius={[0, 4, 4, 0]}>
{importanceData.map((entry, index) => (
<Cell key={index} fill={getBarColor(entry)} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
{/* Legend */}
<div className="flex gap-6 justify-center mt-4 text-sm border-t border-dark-500 pt-4">
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded" style={{ backgroundColor: '#34d399' }} />
<span className="text-dark-300">Negative correlation (helps minimize)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded" style={{ backgroundColor: '#f87171' }} />
<span className="text-dark-300">Positive correlation (hurts minimize)</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,278 @@
/**
* Study Report Viewer
* Displays the STUDY_REPORT.md file with proper markdown rendering
* Includes math equation support via KaTeX and syntax highlighting
*/
import { useState, useEffect } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import rehypeKatex from 'rehype-katex';
import 'katex/dist/katex.min.css';
import { FileText, X, ExternalLink, RefreshCw } from 'lucide-react';
interface StudyReportViewerProps {
studyId: string;
studyPath?: string;
}
export function StudyReportViewer({ studyId, studyPath }: StudyReportViewerProps) {
const [isOpen, setIsOpen] = useState(false);
const [markdown, setMarkdown] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchReport = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(`/api/optimization/studies/${studyId}/report`);
if (!response.ok) {
if (response.status === 404) {
setError('No STUDY_REPORT.md found for this study');
} else {
throw new Error(`HTTP ${response.status}`);
}
return;
}
const data = await response.json();
setMarkdown(data.content);
} catch (err) {
setError(`Failed to load report: ${err}`);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (isOpen && !markdown) {
fetchReport();
}
}, [isOpen, studyId]);
if (!isOpen) {
return (
<button
onClick={() => setIsOpen(true)}
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg transition-colors text-sm font-medium"
>
<FileText size={16} />
Study Report
</button>
);
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70">
<div className="bg-dark-800 rounded-xl shadow-2xl w-[90vw] max-w-5xl h-[85vh] flex flex-col border border-dark-600">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-dark-600">
<div className="flex items-center gap-3">
<FileText className="text-primary-400" size={24} />
<div>
<h2 className="text-lg font-semibold text-dark-100">Study Report</h2>
<p className="text-sm text-dark-400">{studyId}/STUDY_REPORT.md</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={fetchReport}
className="p-2 hover:bg-dark-600 rounded-lg transition-colors"
title="Refresh"
>
<RefreshCw size={18} className={`text-dark-300 ${loading ? 'animate-spin' : ''}`} />
</button>
{studyPath && (
<a
href={`file:///${studyPath.replace(/\\/g, '/')}/STUDY_REPORT.md`}
target="_blank"
rel="noopener noreferrer"
className="p-2 hover:bg-dark-600 rounded-lg transition-colors"
title="Open in editor"
>
<ExternalLink size={18} className="text-dark-300" />
</a>
)}
<button
onClick={() => setIsOpen(false)}
className="p-2 hover:bg-dark-600 rounded-lg transition-colors"
>
<X size={20} className="text-dark-300" />
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-auto p-8 bg-dark-700">
{loading && (
<div className="flex items-center justify-center h-full">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-400"></div>
</div>
)}
{error && (
<div className="flex flex-col items-center justify-center h-full text-dark-400">
<FileText size={48} className="mb-4 opacity-50" />
<p>{error}</p>
<button
onClick={fetchReport}
className="mt-4 px-4 py-2 text-sm bg-dark-600 hover:bg-dark-500 text-dark-200 rounded-lg transition-colors"
>
Try Again
</button>
</div>
)}
{markdown && !loading && (
<article className="markdown-body">
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeKatex]}
components={{
// Custom heading styles
h1: ({children}) => (
<h1 className="text-3xl font-bold text-dark-50 mb-6 pb-3 border-b border-dark-500">
{children}
</h1>
),
h2: ({children}) => (
<h2 className="text-2xl font-semibold text-dark-100 mt-8 mb-4 pb-2 border-b border-dark-600">
{children}
</h2>
),
h3: ({children}) => (
<h3 className="text-xl font-semibold text-dark-100 mt-6 mb-3">
{children}
</h3>
),
h4: ({children}) => (
<h4 className="text-lg font-medium text-dark-200 mt-4 mb-2">
{children}
</h4>
),
// Paragraph styling
p: ({children}) => (
<p className="text-dark-200 leading-relaxed mb-4">
{children}
</p>
),
// List styling
ul: ({children}) => (
<ul className="list-disc list-inside text-dark-200 mb-4 space-y-1 ml-2">
{children}
</ul>
),
ol: ({children}) => (
<ol className="list-decimal list-inside text-dark-200 mb-4 space-y-1 ml-2">
{children}
</ol>
),
li: ({children}) => (
<li className="text-dark-200 leading-relaxed">
{children}
</li>
),
// Strong/bold text
strong: ({children}) => (
<strong className="font-semibold text-dark-100">
{children}
</strong>
),
// Emphasis/italic
em: ({children}) => (
<em className="italic text-dark-200">
{children}
</em>
),
// Links
a: ({href, children}) => (
<a
href={href}
className="text-primary-400 hover:text-primary-300 underline decoration-primary-400/30 hover:decoration-primary-300"
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
),
// Inline code
code: ({className, children, ...props}) => {
const isInline = !className;
if (isInline) {
return (
<code className="bg-dark-800 text-primary-300 px-1.5 py-0.5 rounded text-sm font-mono">
{children}
</code>
);
}
// Code block
return (
<code className={`${className} text-sm`} {...props}>
{children}
</code>
);
},
// Code blocks with pre wrapper
pre: ({children}) => (
<pre className="bg-dark-900 text-dark-100 p-4 rounded-lg overflow-x-auto mb-4 text-sm font-mono border border-dark-600">
{children}
</pre>
),
// Blockquotes
blockquote: ({children}) => (
<blockquote className="border-l-4 border-primary-500 pl-4 py-1 my-4 bg-dark-800/50 rounded-r italic text-dark-300">
{children}
</blockquote>
),
// Tables
table: ({children}) => (
<div className="overflow-x-auto mb-6">
<table className="min-w-full border-collapse border border-dark-500 text-sm">
{children}
</table>
</div>
),
thead: ({children}) => (
<thead className="bg-dark-600">
{children}
</thead>
),
th: ({children}) => (
<th className="border border-dark-500 px-4 py-2 text-left font-semibold text-dark-100">
{children}
</th>
),
td: ({children}) => (
<td className="border border-dark-500 px-4 py-2 text-dark-200">
{children}
</td>
),
tr: ({children}) => (
<tr className="hover:bg-dark-600/50 transition-colors">
{children}
</tr>
),
// Horizontal rule
hr: () => (
<hr className="border-dark-500 my-8" />
),
// Images
img: ({src, alt}) => (
<img
src={src}
alt={alt || ''}
className="max-w-full h-auto rounded-lg border border-dark-500 my-4"
/>
),
}}
>
{markdown}
</ReactMarkdown>
</article>
)}
</div>
</div>
</div>
);
}