diff --git a/resources/public/android-chrome-192x192.png b/resources/public/android-chrome-192x192.png
new file mode 100644
index 00000000..9a0959dd
Binary files /dev/null and b/resources/public/android-chrome-192x192.png differ
diff --git a/resources/public/android-chrome-512x512.png b/resources/public/android-chrome-512x512.png
new file mode 100644
index 00000000..823d4417
Binary files /dev/null and b/resources/public/android-chrome-512x512.png differ
diff --git a/resources/public/apple-touch-icon.png b/resources/public/apple-touch-icon.png
new file mode 100644
index 00000000..76e1b4d9
Binary files /dev/null and b/resources/public/apple-touch-icon.png differ
diff --git a/resources/public/favicon-16x16.png b/resources/public/favicon-16x16.png
new file mode 100644
index 00000000..7264d73b
Binary files /dev/null and b/resources/public/favicon-16x16.png differ
diff --git a/resources/public/favicon-32x32.png b/resources/public/favicon-32x32.png
new file mode 100644
index 00000000..e54f1592
Binary files /dev/null and b/resources/public/favicon-32x32.png differ
diff --git a/resources/public/favicon.ico b/resources/public/favicon.ico
new file mode 100644
index 00000000..1d60f596
Binary files /dev/null and b/resources/public/favicon.ico differ
diff --git a/resources/public/js/solver-charts.js b/resources/public/js/solver-charts.js
new file mode 100644
index 00000000..ca9d798f
--- /dev/null
+++ b/resources/public/js/solver-charts.js
@@ -0,0 +1,793 @@
+/**
+ * Chart-related functions for the solver
+ */
+
+// Chart instances
+let fitChart = null;
+let dataEditorChart = null;
+let editorData = [];
+let historyCharts = {};
+let scoreCharts = {};
+
+// Brush state
+let currentBrushMode = 'point';
+let brushSize = 3;
+let brushDragging = false;
+let brushStartY = null;
+
+// Set brush mode and update UI
+function setBrushMode(mode) {
+ currentBrushMode = mode;
+
+ // Update button styles
+ document.querySelectorAll('.brush-mode-btn').forEach(btn => {
+ btn.classList.remove('bg-blue-600', 'text-white');
+ btn.classList.add('text-gray-300');
+ });
+ const activeBtn = document.getElementById(`brush-${mode}`);
+ if (activeBtn) {
+ activeBtn.classList.remove('text-gray-300');
+ activeBtn.classList.add('bg-blue-600', 'text-white');
+ }
+
+ // Show/hide brush size control
+ const sizeContainer = document.getElementById('brush-size-container');
+ if (sizeContainer) {
+ sizeContainer.classList.toggle('hidden', mode === 'point');
+ }
+
+ // Update hint text
+ const hint = document.getElementById('brush-hint');
+ if (hint) {
+ const hints = {
+ 'point': '(drag points to adjust Y values)',
+ 'smooth': '(click and drag to smooth nearby points)',
+ 'bump': '(drag up/down to raise/lower nearby points)',
+ 'flatten': '(click and drag to flatten nearby points)'
+ };
+ hint.textContent = hints[mode] || '';
+ }
+
+ // Reinitialize handlers for the new mode
+ if (dataEditorChart && editorData.length > 0) {
+ setupDragHandlers();
+ }
+}
+
+// Update brush size
+function updateBrushSize(value) {
+ brushSize = parseInt(value);
+ const sizeValue = document.getElementById('brush-size-value');
+ if (sizeValue) {
+ sizeValue.textContent = value;
+ }
+}
+
+// Initialize the data editor chart
+function initDataEditorChart() {
+ const container = document.getElementById('data-editor-chart');
+ if (!container) return;
+
+ // Dispose existing chart to ensure clean state
+ if (dataEditorChart) {
+ dataEditorChart.dispose();
+ dataEditorChart = null;
+ }
+
+ dataEditorChart = echarts.init(container, 'dark');
+ dataEditorChart.setOption({ animation: false });
+
+ const xs = document.getElementById('xs').value.split(',').map(s => parseFloat(s.trim())).filter(n => !isNaN(n));
+ const ys = document.getElementById('ys').value.split(',').map(s => parseFloat(s.trim())).filter(n => !isNaN(n));
+
+ if (xs.length === 0 || ys.length === 0) {
+ dataEditorChart.clear();
+ return;
+ }
+
+ editorData = xs.map((x, i) => ({ x, y: ys[i] !== undefined ? ys[i] : 0, index: i }));
+ updateDataEditorChart();
+}
+
+// Calculate symbol size based on number of points
+function getEditorSymbolSize(numPoints) {
+ if (numPoints <= 10) return 12;
+ if (numPoints <= 25) return 10;
+ if (numPoints <= 50) return 8;
+ if (numPoints <= 100) return 6;
+ return 4;
+}
+
+// Update the data editor chart display
+function updateDataEditorChart() {
+ if (!dataEditorChart || editorData.length === 0) return;
+
+ const symbolSize = getEditorSymbolSize(editorData.length);
+
+ const option = {
+ backgroundColor: 'transparent',
+ grid: { left: '12%', right: '5%', top: '10%', bottom: '15%' },
+ xAxis: {
+ type: 'value',
+ axisLine: { lineStyle: { color: '#4b5563' } },
+ axisLabel: { color: '#9ca3af', fontSize: 10 },
+ splitLine: { lineStyle: { color: '#374151' } }
+ },
+ yAxis: {
+ type: 'value',
+ axisLine: { lineStyle: { color: '#4b5563' } },
+ axisLabel: { color: '#9ca3af', fontSize: 10 },
+ splitLine: { lineStyle: { color: '#374151' } }
+ },
+ series: [{
+ type: 'scatter',
+ symbolSize: symbolSize,
+ data: editorData.map(d => [d.x, d.y]),
+ itemStyle: { color: '#3b82f6' },
+ cursor: 'ns-resize'
+ }]
+ };
+
+ dataEditorChart.setOption(option);
+ setupDragHandlers();
+}
+
+// Get indices of points within brush radius of a given index
+function getPointsInBrushRadius(centerIdx) {
+ const indices = [];
+ for (let i = Math.max(0, centerIdx - brushSize); i <= Math.min(editorData.length - 1, centerIdx + brushSize); i++) {
+ indices.push(i);
+ }
+ return indices;
+}
+
+// Calculate Gaussian weight for distance from center
+function gaussianWeight(distance, sigma) {
+ return Math.exp(-(distance * distance) / (2 * sigma * sigma));
+}
+
+// Apply smooth brush: weighted average with neighbors
+function applySmooth(centerIdx) {
+ const indices = getPointsInBrushRadius(centerIdx);
+ if (indices.length < 2) return;
+
+ const sigma = brushSize / 2;
+
+ // Calculate smoothed values for affected points
+ indices.forEach(idx => {
+ let weightedSum = 0;
+ let weightSum = 0;
+
+ indices.forEach(neighborIdx => {
+ const distance = Math.abs(neighborIdx - idx);
+ const weight = gaussianWeight(distance, sigma);
+ weightedSum += editorData[neighborIdx].y * weight;
+ weightSum += weight;
+ });
+
+ // Blend original with smoothed (strength based on proximity to brush center)
+ const centerDistance = Math.abs(idx - centerIdx);
+ const blendFactor = gaussianWeight(centerDistance, sigma) * 0.5;
+ const smoothedY = weightedSum / weightSum;
+ editorData[idx].y = editorData[idx].y * (1 - blendFactor) + smoothedY * blendFactor;
+ });
+}
+
+// Apply bump brush: raise or lower points based on delta Y
+function applyBump(centerIdx, deltaY) {
+ const indices = getPointsInBrushRadius(centerIdx);
+ const sigma = brushSize / 2;
+
+ indices.forEach(idx => {
+ const distance = Math.abs(idx - centerIdx);
+ const weight = gaussianWeight(distance, sigma);
+ editorData[idx].y += deltaY * weight * 0.3; // Scale factor for smoother control
+ });
+}
+
+// Apply flatten brush: move points toward their local average
+function applyFlatten(centerIdx) {
+ const indices = getPointsInBrushRadius(centerIdx);
+ if (indices.length < 2) return;
+
+ // Calculate local average
+ const avg = indices.reduce((sum, idx) => sum + editorData[idx].y, 0) / indices.length;
+ const sigma = brushSize / 2;
+
+ // Move points toward average
+ indices.forEach(idx => {
+ const distance = Math.abs(idx - centerIdx);
+ const weight = gaussianWeight(distance, sigma) * 0.3; // Blend factor
+ editorData[idx].y = editorData[idx].y * (1 - weight) + avg * weight;
+ });
+}
+
+// Find nearest data point index to a pixel position
+function findNearestPointIndex(pixelX) {
+ if (!dataEditorChart || editorData.length === 0) return -1;
+
+ let nearestIdx = 0;
+ let nearestDist = Infinity;
+
+ try {
+ editorData.forEach((d, idx) => {
+ const pointPixel = dataEditorChart.convertToPixel('grid', [d.x, d.y]);
+ if (pointPixel) {
+ const dist = Math.abs(pointPixel[0] - pixelX);
+ if (dist < nearestDist) {
+ nearestDist = dist;
+ nearestIdx = idx;
+ }
+ }
+ });
+ } catch (e) {
+ return -1;
+ }
+
+ return nearestIdx;
+}
+
+// Refresh the chart display
+function refreshEditorChart() {
+ if (!dataEditorChart) return;
+ try {
+ dataEditorChart.setOption({
+ series: [{ data: editorData.map(d => [d.x, d.y]) }]
+ }, false);
+ } catch (e) {
+ console.warn('refreshEditorChart error:', e);
+ }
+}
+
+// Store brush handler references so we can remove them specifically
+let brushMousedownHandler = null;
+let brushMousemoveHandler = null;
+let brushMouseupHandler = null;
+let brushGlobaloutHandler = null;
+
+// Remove only our custom brush handlers (not all handlers)
+function removeBrushHandlers() {
+ if (!dataEditorChart) return;
+ const zr = dataEditorChart.getZr();
+ if (brushMousedownHandler) {
+ zr.off('mousedown', brushMousedownHandler);
+ brushMousedownHandler = null;
+ }
+ if (brushMousemoveHandler) {
+ zr.off('mousemove', brushMousemoveHandler);
+ brushMousemoveHandler = null;
+ }
+ if (brushMouseupHandler) {
+ zr.off('mouseup', brushMouseupHandler);
+ brushMouseupHandler = null;
+ }
+ if (brushGlobaloutHandler) {
+ zr.off('globalout', brushGlobaloutHandler);
+ brushGlobaloutHandler = null;
+ }
+}
+
+// Set up drag handlers for points
+function setupDragHandlers() {
+ if (!dataEditorChart || editorData.length === 0) return;
+
+ // Remove only our custom brush handlers (preserve ECharts internal handlers)
+ removeBrushHandlers();
+
+ const container = document.getElementById('data-editor-chart');
+
+ if (currentBrushMode === 'point') {
+ // Reset cursor for point mode
+ if (container) {
+ container.style.cursor = 'default';
+ }
+
+ // Point mode: handle dragging via zrender events (more reliable than graphic draggable)
+ const zr = dataEditorChart.getZr();
+ let draggingIdx = -1;
+ let lastY = null;
+
+ brushMousedownHandler = function(e) {
+ const nearestIdx = findNearestPointIndex(e.offsetX);
+ if (nearestIdx < 0) return;
+
+ // Check if click is close enough to a point (within ~20 pixels)
+ const pointPixel = dataEditorChart.convertToPixel('grid', [editorData[nearestIdx].x, editorData[nearestIdx].y]);
+ const dist = Math.sqrt(Math.pow(e.offsetX - pointPixel[0], 2) + Math.pow(e.offsetY - pointPixel[1], 2));
+ if (dist > 20) return;
+
+ draggingIdx = nearestIdx;
+ lastY = e.offsetY;
+ };
+
+ brushMousemoveHandler = function(e) {
+ if (draggingIdx < 0) return;
+
+ // Convert pixel Y to data Y
+ const dataPos = dataEditorChart.convertFromPixel('grid', [0, e.offsetY]);
+ editorData[draggingIdx].y = dataPos[1];
+ lastY = e.offsetY;
+
+ // Update chart
+ dataEditorChart.setOption({
+ series: [{ data: editorData.map(d => [d.x, d.y]) }]
+ });
+ };
+
+ brushMouseupHandler = function(e) {
+ if (draggingIdx >= 0) {
+ draggingIdx = -1;
+ syncEditorToTextarea();
+ }
+ };
+
+ brushGlobaloutHandler = function(e) {
+ if (draggingIdx >= 0) {
+ draggingIdx = -1;
+ syncEditorToTextarea();
+ }
+ };
+
+ zr.on('mousedown', brushMousedownHandler);
+ zr.on('mousemove', brushMousemoveHandler);
+ zr.on('mouseup', brushMouseupHandler);
+ zr.on('globalout', brushGlobaloutHandler);
+ } else {
+ // Brush modes: use zrender mouse events on the chart area
+
+ // Set cursor for brush modes
+ if (container) {
+ container.style.cursor = currentBrushMode === 'bump' ? 'ns-resize' : 'crosshair';
+ }
+
+ const zr = dataEditorChart.getZr();
+ let lastY = null;
+ let lastIdx = null;
+
+ // Define handlers as named functions so we can remove them specifically
+ brushMousedownHandler = function(e) {
+ // Check if click is within the grid area
+ try {
+ const gridModel = dataEditorChart.getModel().getComponent('grid');
+ if (gridModel && gridModel.coordinateSystem) {
+ const gridRect = gridModel.coordinateSystem.getRect();
+ if (e.offsetX < gridRect.x || e.offsetX > gridRect.x + gridRect.width ||
+ e.offsetY < gridRect.y || e.offsetY > gridRect.y + gridRect.height) {
+ return;
+ }
+ }
+ } catch (err) {
+ // If we can't get the grid rect, proceed anyway
+ }
+
+ brushDragging = true;
+ brushStartY = e.offsetY;
+ lastY = e.offsetY;
+
+ const nearestIdx = findNearestPointIndex(e.offsetX);
+ lastIdx = nearestIdx;
+
+ if (nearestIdx >= 0) {
+ if (currentBrushMode === 'smooth') {
+ applySmooth(nearestIdx);
+ refreshEditorChart();
+ } else if (currentBrushMode === 'flatten') {
+ applyFlatten(nearestIdx);
+ refreshEditorChart();
+ }
+ }
+ };
+
+ brushMousemoveHandler = function(e) {
+ if (!brushDragging) return;
+
+ const nearestIdx = findNearestPointIndex(e.offsetX);
+ if (nearestIdx < 0) return;
+
+ if (currentBrushMode === 'bump') {
+ if (Math.abs(e.offsetY - lastY) > 1) {
+ // Convert pixel delta to data delta (invert because screen Y is opposite to data Y)
+ const dataCenter = dataEditorChart.convertFromPixel('grid', [0, lastY]);
+ const dataNew = dataEditorChart.convertFromPixel('grid', [0, e.offsetY]);
+ const dataDelta = dataNew[1] - dataCenter[1];
+ applyBump(nearestIdx, dataDelta);
+ lastY = e.offsetY;
+ refreshEditorChart();
+ }
+ } else if (currentBrushMode === 'smooth') {
+ if (nearestIdx !== lastIdx) {
+ applySmooth(nearestIdx);
+ lastIdx = nearestIdx;
+ refreshEditorChart();
+ }
+ } else if (currentBrushMode === 'flatten') {
+ if (nearestIdx !== lastIdx) {
+ applyFlatten(nearestIdx);
+ lastIdx = nearestIdx;
+ refreshEditorChart();
+ }
+ }
+ };
+
+ brushMouseupHandler = function(e) {
+ if (brushDragging) {
+ brushDragging = false;
+ syncEditorToTextarea();
+ }
+ };
+
+ brushGlobaloutHandler = function(e) {
+ if (brushDragging) {
+ brushDragging = false;
+ syncEditorToTextarea();
+ }
+ };
+
+ // Register the handlers
+ zr.on('mousedown', brushMousedownHandler);
+ zr.on('mousemove', brushMousemoveHandler);
+ zr.on('mouseup', brushMouseupHandler);
+ zr.on('globalout', brushGlobaloutHandler);
+ }
+}
+
+// Update drag handler positions - no longer needed since we use zrender events
+// Kept for compatibility but does nothing now
+function updateDragHandlerPositions() {
+ // No-op: we no longer use graphic elements for point dragging
+}
+
+// Sync editor data to Y values textarea
+// skipClearDatasetName: set to true when syncing for form submission (data wasn't manually edited)
+function syncEditorToTextarea(skipClearDatasetName = false) {
+ const ys = editorData.map(d => d.y.toFixed(6));
+ document.getElementById('ys').value = ys.join(', ');
+ // Clear dataset name only if user manually edited via drag (not during form submission sync)
+ if (!skipClearDatasetName) {
+ clearDatasetName();
+ }
+}
+
+// Initialize or update the fit chart
+// Optional customXs/customYs parameters for multi-job support
+function renderFitChart(formula, animate = true, customXs = null, customYs = null) {
+ const chartContainer = document.getElementById('fit-chart');
+ if (!chartContainer) return;
+
+ if (!fitChart) {
+ fitChart = echarts.init(chartContainer, 'dark');
+ }
+
+ // Use custom xs/ys if provided, otherwise fall back to global inputXs/inputYs
+ const xs = customXs || inputXs;
+ const ys = customYs || inputYs;
+
+ const dataPoints = xs.map((x, i) => ({ x: x, y: ys[i] }));
+ dataPoints.sort((a, b) => a.x - b.x);
+ const sortedXs = dataPoints.map(p => p.x);
+ const sortedYs = dataPoints.map(p => p.y);
+
+ const mathJsFormula = convertFormula(formula);
+
+ try {
+ const node = math.parse(mathJsFormula);
+ const compiled = node.compile();
+
+ const dataXMin = Math.min(...sortedXs);
+ const dataXMax = Math.max(...sortedXs);
+ const range = dataXMax - dataXMin;
+ const extension = range * 0.15;
+ const xMin = dataXMin - extension;
+ const xMax = dataXMax + extension;
+ const step = (xMax - xMin) / 100;
+ const curveXs = [];
+ const curveYs = [];
+
+ // Calculate y-axis bounds from objective data with padding
+ const dataYMin = Math.min(...sortedYs);
+ const dataYMax = Math.max(...sortedYs);
+ const yRange = dataYMax - dataYMin;
+ const yPadding = Math.max(yRange * 0.5, Math.abs(dataYMax) * 0.1, Math.abs(dataYMin) * 0.1, 1);
+ const yAxisMin = Math.floor(dataYMin - yPadding);
+ const yAxisMax = Math.ceil(dataYMax + yPadding);
+
+ // Clipping bounds with small margin so line visibly exits the chart
+ const clipMax = yAxisMax + yPadding * 0.1;
+ const clipMin = yAxisMin - yPadding * 0.1;
+
+ for (let x = xMin; x <= xMax; x += step) {
+ curveXs.push(x);
+ try {
+ const y = compiled.evaluate({ x: x });
+ // Clip curve values to axis bounds
+ if (!isFinite(y)) {
+ curveYs.push(null);
+ } else if (y > clipMax) {
+ curveYs.push(clipMax);
+ } else if (y < clipMin) {
+ curveYs.push(clipMin);
+ } else {
+ curveYs.push(y);
+ }
+ } catch (e) {
+ curveYs.push(null);
+ }
+ }
+
+ const option = {
+ animation: animate,
+ backgroundColor: 'transparent',
+ tooltip: {
+ trigger: 'axis',
+ backgroundColor: '#1f2937',
+ borderColor: '#374151',
+ textStyle: { color: '#f3f4f6' }
+ },
+ legend: {
+ data: ['Objective Data', 'Best Function'],
+ textStyle: { color: '#9ca3af' },
+ top: 10
+ },
+ grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
+ xAxis: {
+ type: 'value',
+ name: 'x',
+ nameTextStyle: { color: '#9ca3af' },
+ axisLine: { lineStyle: { color: '#4b5563' } },
+ axisLabel: { color: '#9ca3af' },
+ splitLine: { lineStyle: { color: '#374151' } }
+ },
+ yAxis: {
+ type: 'value',
+ name: 'y',
+ nameTextStyle: { color: '#9ca3af' },
+ axisLine: { lineStyle: { color: '#4b5563' } },
+ axisLabel: { color: '#9ca3af' },
+ splitLine: { lineStyle: { color: '#374151' } },
+ min: yAxisMin,
+ max: yAxisMax
+ },
+ series: [
+ {
+ name: 'Objective Data',
+ type: 'scatter',
+ symbol: 'circle',
+ symbolSize: 8,
+ data: sortedXs.map((x, i) => [x, sortedYs[i]]),
+ itemStyle: { color: '#3b82f6' }
+ },
+ {
+ name: 'Best Function',
+ type: 'line',
+ smooth: true,
+ showSymbol: false,
+ data: curveXs.map((x, i) => [x, curveYs[i]]),
+ lineStyle: { color: '#22c55e', width: 2 },
+ itemStyle: { color: '#22c55e' }
+ }
+ ]
+ };
+
+ fitChart.setOption(option);
+ } catch (e) {
+ console.error('Error evaluating formula:', e);
+ chartContainer.innerHTML = 'Could not evaluate formula for charting
';
+ fitChart = null;
+ }
+}
+
+// Render chart for a history item
+function renderHistoryChart(index) {
+ const job = jobHistory[index];
+ const container = document.getElementById(`history-chart-${index}`);
+ if (!container || !job) return;
+
+ if (historyCharts[index]) {
+ historyCharts[index].dispose();
+ }
+
+ const chart = echarts.init(container, 'dark');
+ historyCharts[index] = chart;
+
+ const dataPoints = job.xs.map((x, i) => ({ x, y: job.ys[i] }));
+ dataPoints.sort((a, b) => a.x - b.x);
+ const sortedXs = dataPoints.map(p => p.x);
+ const sortedYs = dataPoints.map(p => p.y);
+
+ // Calculate y-axis bounds from objective data with padding
+ const dataYMin = Math.min(...sortedYs);
+ const dataYMax = Math.max(...sortedYs);
+ const yRange = dataYMax - dataYMin;
+ const yPadding = Math.max(yRange * 0.5, Math.abs(dataYMax) * 0.1, Math.abs(dataYMin) * 0.1, 1);
+ const yAxisMin = Math.floor(dataYMin - yPadding);
+ const yAxisMax = Math.ceil(dataYMax + yPadding);
+
+ // Clipping bounds with small margin so line visibly exits the chart
+ const clipMax = yAxisMax + yPadding * 0.1;
+ const clipMin = yAxisMin - yPadding * 0.1;
+
+ const mathJsFormula = convertFormula(job.formula);
+ let curveXs = [], curveYs = [];
+ try {
+ const node = math.parse(mathJsFormula);
+ const compiled = node.compile();
+ const xMin = Math.min(...sortedXs);
+ const xMax = Math.max(...sortedXs);
+ const range = xMax - xMin;
+ const step = range / 50;
+ for (let x = xMin - range * 0.1; x <= xMax + range * 0.1; x += step) {
+ curveXs.push(x);
+ try {
+ const y = compiled.evaluate({ x });
+ // Clip curve values to axis bounds
+ if (!isFinite(y)) {
+ curveYs.push(null);
+ } else if (y > clipMax) {
+ curveYs.push(clipMax);
+ } else if (y < clipMin) {
+ curveYs.push(clipMin);
+ } else {
+ curveYs.push(y);
+ }
+ } catch (e) { curveYs.push(null); }
+ }
+ } catch (e) { console.error('Chart error:', e); }
+
+ chart.setOption({
+ animation: false,
+ backgroundColor: 'transparent',
+ grid: { left: '10%', right: '5%', top: '10%', bottom: '15%' },
+ xAxis: { type: 'value', axisLine: { lineStyle: { color: '#4b5563' } }, axisLabel: { color: '#9ca3af', fontSize: 10 }, splitLine: { lineStyle: { color: '#374151' } } },
+ yAxis: { type: 'value', min: yAxisMin, max: yAxisMax, axisLine: { lineStyle: { color: '#4b5563' } }, axisLabel: { color: '#9ca3af', fontSize: 10 }, splitLine: { lineStyle: { color: '#374151' } } },
+ series: [
+ { type: 'scatter', symbolSize: 6, data: sortedXs.map((x, i) => [x, sortedYs[i]]), itemStyle: { color: '#3b82f6' } },
+ { type: 'line', smooth: true, showSymbol: false, data: curveXs.map((x, i) => [x, curveYs[i]]), lineStyle: { color: '#22c55e', width: 2 } }
+ ]
+ });
+}
+
+// Dispose fit chart
+function disposeFitChart() {
+ if (fitChart) {
+ fitChart.dispose();
+ fitChart = null;
+ }
+}
+
+// Render a compact score progression chart (logarithmic Y-axis)
+function renderScoreChart(containerId, scoreHistory, chartKey = null) {
+ const container = document.getElementById(containerId);
+ if (!container || !scoreHistory || scoreHistory.length === 0) {
+ if (container) container.innerHTML = 'Waiting for data...
';
+ return;
+ }
+
+ // Need at least 2 points to draw a line
+ if (scoreHistory.length < 2) {
+ container.innerHTML = 'Collecting data...
';
+ return;
+ }
+
+ // Dispose existing chart if using a keyed chart
+ const key = chartKey || containerId;
+ if (scoreCharts[key]) {
+ scoreCharts[key].dispose();
+ }
+
+ const chart = echarts.init(container, 'dark');
+ scoreCharts[key] = chart;
+
+ // Prepare data - scores should decrease (better), so we show them going down
+ const data = scoreHistory.map(h => [h.iteration, h.score]);
+
+ // Calculate if log scale is appropriate (scores span more than 2 orders of magnitude)
+ const scores = scoreHistory.map(h => h.score).filter(s => s > 0);
+ const minScore = Math.min(...scores);
+ const maxScore = Math.max(...scores);
+ const useLogScale = maxScore / minScore > 100;
+
+ // Add some padding to Y range
+ const yPadding = (maxScore - minScore) * 0.1 || maxScore * 0.1;
+
+ const option = {
+ animation: false,
+ backgroundColor: 'transparent',
+ grid: {
+ left: 50,
+ right: 10,
+ top: 10,
+ bottom: 25
+ },
+ tooltip: {
+ trigger: 'axis',
+ backgroundColor: '#1f2937',
+ borderColor: '#374151',
+ textStyle: { color: '#f3f4f6', fontSize: 11 },
+ formatter: function(params) {
+ const p = params[0];
+ return `Iter ${p.data[0]}: ${p.data[1].toExponential(2)}`;
+ }
+ },
+ xAxis: {
+ type: 'value',
+ name: 'iter',
+ nameLocation: 'middle',
+ nameGap: 12,
+ nameTextStyle: { color: '#6b7280', fontSize: 10 },
+ axisLine: { lineStyle: { color: '#374151' } },
+ axisLabel: { color: '#6b7280', fontSize: 9 },
+ splitLine: { show: false },
+ min: 0
+ },
+ yAxis: {
+ type: 'value',
+ name: '',
+ axisLine: { lineStyle: { color: '#374151' } },
+ axisLabel: {
+ color: '#6b7280',
+ fontSize: 9,
+ formatter: function(val) {
+ if (Math.abs(val) >= 1000000) return val.toExponential(0);
+ if (Math.abs(val) >= 1000) return (val/1000).toFixed(0) + 'k';
+ if (Math.abs(val) >= 1) return val.toFixed(0);
+ if (Math.abs(val) >= 0.01) return val.toFixed(2);
+ return val.toExponential(0);
+ }
+ },
+ splitLine: { lineStyle: { color: '#374151', opacity: 0.5 } },
+ min: Math.max(0, minScore - yPadding),
+ max: maxScore + yPadding
+ },
+ series: [{
+ type: 'line',
+ smooth: true,
+ showSymbol: scoreHistory.length < 20,
+ symbolSize: 2,
+ data: data,
+ lineStyle: { color: '#f59e0b', width: 1 },
+ itemStyle: { color: '#f59e0b' },
+ areaStyle: {
+ color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+ { offset: 0, color: 'rgba(245, 158, 11, 0.3)' },
+ { offset: 1, color: 'rgba(245, 158, 11, 0.05)' }
+ ])
+ }
+ }]
+ };
+
+ chart.setOption(option);
+}
+
+// Dispose a score chart by key
+function disposeScoreChart(key) {
+ if (scoreCharts[key]) {
+ scoreCharts[key].dispose();
+ delete scoreCharts[key];
+ }
+}
+
+// Generate inline SVG sparkline for a specific scoring method from score history
+function generateMethodSparkline(scoreHistory, methodKey, width = 80, height = 20) {
+ if (!scoreHistory || scoreHistory.length < 2) return '';
+
+ // Extract scores for this method
+ const scores = scoreHistory
+ .map(h => h[methodKey])
+ .filter(s => s !== undefined && s !== null && isFinite(s));
+
+ if (scores.length < 2) return '';
+
+ const minScore = Math.min(...scores);
+ const maxScore = Math.max(...scores);
+ const range = maxScore - minScore || 1;
+
+ const points = scores.map((score, i) => {
+ const x = (i / (scores.length - 1)) * width;
+ const y = height - ((score - minScore) / range) * (height - 2) - 1;
+ return `${x.toFixed(1)},${y.toFixed(1)}`;
+ }).join(' ');
+
+ return ``;
+}
diff --git a/resources/public/js/solver-formula.js b/resources/public/js/solver-formula.js
new file mode 100644
index 00000000..e2e8f2b1
--- /dev/null
+++ b/resources/public/js/solver-formula.js
@@ -0,0 +1,146 @@
+/**
+ * Formula conversion and LaTeX rendering
+ */
+
+// Convert formula from symbolic regression format to math.js format
+function convertFormula(formula) {
+ let converted = formula
+ .replace(/Log\(/g, 'log(')
+ .replace(/Exp\(/g, 'exp(')
+ .replace(/Sqrt\(/g, 'sqrt(')
+ .replace(/Abs\(/g, 'abs(')
+ .replace(/Asin\(/g, 'asin(')
+ .replace(/ArcSin\(/g, 'asin(')
+ .replace(/Acos\(/g, 'acos(')
+ .replace(/ArcCos\(/g, 'acos(')
+ .replace(/Atan\(/g, 'atan(')
+ .replace(/ArcTan\(/g, 'atan(')
+ .replace(/Sinh\(/g, 'sinh(')
+ .replace(/Cosh\(/g, 'cosh(')
+ .replace(/Tanh\(/g, 'tanh(')
+ .replace(/Sec\(/g, 'sec(')
+ .replace(/Csc\(/g, 'csc(')
+ .replace(/Cot\(/g, 'cot(')
+ .replace(/Sin\(/g, 'sin(')
+ .replace(/Cos\(/g, 'cos(')
+ .replace(/Tan\(/g, 'tan(')
+ .replace(/\bPi\b/g, 'pi')
+ .replace(/\bE\b/g, 'e');
+ return converted;
+}
+
+// Add line breaks to long LaTeX formulas
+function wrapLatexWithLineBreaks(latex, termsPerLine = 2, maxLineLength = 5) {
+ let depth = 0;
+ let termCount = 0;
+ let lineLength = 0;
+ let result = '';
+ let i = 0;
+
+ function addLineBreak() {
+ result += ' \\\\ ';
+ termCount = 0;
+ lineLength = 0;
+ }
+
+ while (i < latex.length) {
+ const char = latex[i];
+
+ if (char === '{' || char === '(' || char === '[') {
+ depth++;
+ result += char;
+ lineLength++;
+ i++;
+ } else if (char === '}' || char === ')' || char === ']') {
+ depth--;
+ result += char;
+ lineLength++;
+ i++;
+ } else if (latex.slice(i, i + 5) === '\\left') {
+ depth++;
+ result += '\\left';
+ lineLength += 5;
+ i += 5;
+ } else if (latex.slice(i, i + 6) === '\\right') {
+ depth--;
+ result += '\\right';
+ lineLength += 6;
+ i += 6;
+ } else if (depth === 0 && (char === '+' || (char === '-' && i > 0))) {
+ termCount++;
+ if (termCount >= termsPerLine || lineLength >= maxLineLength) {
+ addLineBreak();
+ }
+ result += char;
+ lineLength++;
+ i++;
+ } else {
+ result += char;
+ lineLength++;
+ i++;
+ }
+ }
+
+ if (result.includes('\\\\')) {
+ return '\\begin{aligned} ' + result + ' \\end{aligned}';
+ }
+ return result;
+}
+
+// Copy LaTeX source to clipboard
+function copyLatex(elementId) {
+ const el = document.getElementById(elementId);
+ if (!el || !el.dataset.latex) return;
+ navigator.clipboard.writeText(el.dataset.latex).then(() => {
+ // Brief visual feedback
+ const btn = el.querySelector('.latex-copy-btn');
+ if (btn) {
+ btn.classList.add('text-white');
+ setTimeout(() => btn.classList.remove('text-white'), 200);
+ }
+ });
+}
+
+// Render LaTeX formula to an element using math.js toTex()
+function renderLatex(elementId, formula) {
+ const element = document.getElementById(elementId);
+ if (!element) return;
+ try {
+ const mathJsFormula = convertFormula(formula);
+ const node = math.parse(mathJsFormula);
+ let latex = node.toTex();
+ const wrappedLatex = wrapLatexWithLineBreaks(latex);
+
+ // Store raw LaTeX source for copying
+ element.dataset.latex = latex;
+
+ // Create flex wrapper with scrollable latex and fixed copy button
+ element.innerHTML = '';
+ element.classList.add('group', 'flex', 'items-start', 'gap-2');
+ element.classList.remove('overflow-x-auto'); // Remove from parent, add to child
+
+ // Scrollable latex container
+ const latexContainer = document.createElement('div');
+ latexContainer.className = 'flex-1 min-w-0 overflow-x-auto';
+ katex.render(wrappedLatex, latexContainer, {
+ throwOnError: false,
+ displayMode: true
+ });
+ element.appendChild(latexContainer);
+
+ // Fixed copy button (outside scrollable area)
+ const copyBtn = document.createElement('button');
+ copyBtn.className = 'latex-copy-btn flex-shrink-0 p-1 opacity-0 group-hover:opacity-100 transition-opacity text-gray-400 hover:text-white bg-gray-800 rounded';
+ copyBtn.title = 'Copy LaTeX source';
+ copyBtn.onclick = () => copyLatex(elementId);
+ copyBtn.innerHTML = `
+
+ `;
+ element.appendChild(copyBtn);
+ } catch (e) {
+ console.error('LaTeX render error:', e);
+ element.textContent = formula;
+ }
+}
diff --git a/resources/public/js/solver-history.js b/resources/public/js/solver-history.js
new file mode 100644
index 00000000..3178c259
--- /dev/null
+++ b/resources/public/js/solver-history.js
@@ -0,0 +1,860 @@
+/**
+ * Job history management
+ */
+
+let jobHistory = [];
+let collapsedTrees = new Set(); // Track which job IDs have their children collapsed
+
+// Get display name for scoring method
+function getScoringMethodDisplay(method) {
+ const displays = {
+ 'mae-max': 'MAE + Max',
+ 'log-cosh': 'Log-Cosh',
+ 'r-squared': 'R²'
+ };
+ return displays[method] || method || 'MAE + Max';
+}
+
+// Get display name for simplicity bias
+function getSimplicityBiasDisplay(bias) {
+ const displays = {
+ 'none': 'None',
+ 'tiebreaker': 'Tiebreaker',
+ 'light': 'Light',
+ 'strong': 'Strong'
+ };
+ return displays[bias] || bias || 'Tiebreaker';
+}
+
+// Update job label in history and refresh badge (called on every keystroke)
+function updateHistoryJobLabel(index, newLabel) {
+ const job = jobHistory[index];
+ if (job) {
+ job.label = newLabel.trim() || null;
+
+ // Update just the badge element directly
+ const badgeEl = document.getElementById(`history-label-badge-${index}`);
+ if (badgeEl) {
+ if (job.label) {
+ badgeEl.innerHTML = escapeHistoryHtml(job.label);
+ badgeEl.title = job.label;
+ badgeEl.classList.remove('hidden');
+ } else {
+ badgeEl.innerHTML = '';
+ badgeEl.classList.add('hidden');
+ }
+ }
+ }
+}
+
+// Create editable label HTML for history job
+function createHistoryEditableLabelHtml(index, label) {
+ const escapedLabel = label ? escapeHistoryHtml(label) : '';
+ return `
+
+ Label:
+
+
+ `;
+}
+
+// Escape HTML for history (local version)
+function escapeHistoryHtml(text) {
+ if (!text) return '';
+ const div = document.createElement('div');
+ div.textContent = text;
+ return div.innerHTML;
+}
+
+// Format length deduction display for history
+function formatHistoryLengthDeduction(deduction) {
+ if (!deduction || deduction === 0) return '';
+ return `(−${deduction.toFixed(4)} bias)`;
+}
+
+// Format compact scores for collapsed history view (no sparklines)
+// Shows raw scores (without length deduction) for fair comparison
+function formatCompactHistoryScores(job) {
+ const scores = job.scores;
+ const rawScores = job.rawScores;
+ const lengthDeductions = job.lengthDeductions;
+ const primaryMethod = job.scoringMethod || job.config?.scoringMethod || 'mae-max';
+
+ if (!scores) {
+ // Fallback for old history items without multi-score data
+ return `${job.score.toFixed(6)}`;
+ }
+
+ const methods = [
+ { key: 'mae-max', name: 'MAE' },
+ { key: 'log-cosh', name: 'LC' },
+ { key: 'r-squared', name: 'R²' }
+ ];
+
+ // Use raw scores for display if available
+ const displayScores = rawScores || scores;
+
+ // Check if there's any non-zero deduction for the primary method
+ const primaryDeduction = lengthDeductions ? lengthDeductions[primaryMethod] : 0;
+ const hasDeduction = primaryDeduction && primaryDeduction > 0;
+
+ const scoresHtml = methods.map(m => {
+ const isPrimary = m.key === primaryMethod;
+ const score = displayScores[m.key];
+ const scoreStr = (score !== undefined && score !== null) ? score.toFixed(4) : '--';
+ if (isPrimary) {
+ return `${m.name}: ${scoreStr}*`;
+ } else {
+ return `${m.name}: ${scoreStr}`;
+ }
+ }).join('·');
+
+ return scoresHtml + (hasDeduction ? formatHistoryLengthDeduction(primaryDeduction) : '');
+}
+
+// Format all scores for history details view - with sparklines
+// Shows raw scores (without length deduction) for fair comparison, with deduction shown separately
+function formatHistoryScores(job) {
+ const scores = job.scores;
+ const rawScores = job.rawScores;
+ const lengthDeductions = job.lengthDeductions;
+ const primaryMethod = job.scoringMethod || job.config?.scoringMethod || 'mae-max';
+ const scoreHistory = job.scoreHistory;
+
+ if (!scores) {
+ // Fallback for old history items without multi-score data
+ return `
+ Score:
+ ${job.score.toFixed(6)}
+ (${getScoringMethodDisplay(primaryMethod)})
+
`;
+ }
+
+ const methods = [
+ { key: 'mae-max', name: 'MAE + Max' },
+ { key: 'log-cosh', name: 'Log-Cosh' },
+ { key: 'r-squared', name: 'R²' }
+ ];
+
+ // Use raw scores for display if available
+ const displayScores = rawScores || scores;
+
+ return `
+
Scores (optimized for ${getScoringMethodDisplay(primaryMethod)}):
+
+ ${methods.map(m => {
+ const isPrimary = m.key === primaryMethod;
+ const score = displayScores[m.key];
+ const deduction = lengthDeductions ? lengthDeductions[m.key] : 0;
+ const scoreStr = (score !== undefined && score !== null) ? score.toFixed(6) : '--';
+ const sparkline = generateMethodSparkline(scoreHistory, m.key);
+ const deductionHtml = (deduction && deduction > 0)
+ ? `
−${deduction.toFixed(6)} bias
`
+ : '';
+ return `
+
${m.name}${isPrimary ? ' *' : ''}
+
${scoreStr}
+ ${deductionHtml}
+ ${sparkline}
+
`;
+ }).join('')}
+
+
`;
+}
+
+// Format milliseconds into human-readable duration
+function formatDuration(ms) {
+ if (!ms || ms < 0) return null;
+
+ const seconds = Math.floor(ms / 1000);
+ const hours = Math.floor(seconds / 3600);
+ const minutes = Math.floor((seconds % 3600) / 60);
+ const secs = seconds % 60;
+
+ if (hours > 0) {
+ return `${hours}h ${minutes}m`;
+ } else if (minutes > 0) {
+ return `${minutes}m ${secs}s`;
+ } else {
+ return `${secs}s`;
+ }
+}
+
+// Save job to history
+function saveToHistory(jobData, status = 'completed', scoreHistory = [], elapsedMs = null) {
+ const scoringMethodEl = document.getElementById('scoring-method');
+ const simplicityBiasEl = document.getElementById('simplicity-bias');
+ const job = {
+ id: currentJobId,
+ parentId: jobData['source-job'] || null, // Track parent job for hierarchy
+ timestamp: new Date().toLocaleString(),
+ status: status,
+ datasetName: getSelectedDatasetName(),
+ formula: jobData['best-solution'].formula,
+ score: jobData['best-solution'].score,
+ scores: jobData['best-solution'].scores || null, // All scoring method scores (with length deduction)
+ rawScores: jobData['best-solution'].rawScores || null, // Raw scores without length deduction
+ lengthDeductions: jobData['best-solution'].lengthDeductions || null, // Length deductions per method
+ leafCount: jobData['best-solution'].leafCount,
+ xs: [...inputXs],
+ ys: [...inputYs],
+ config: {
+ iterations: parseInt(document.getElementById('iterations').value) || 100,
+ population: parseInt(document.getElementById('population').value) || 100,
+ maxLeafs: parseInt(document.getElementById('max-leafs').value) || 40,
+ seed: document.getElementById('seed').value || null,
+ scoringMethod: scoringMethodEl ? scoringMethodEl.value : 'mae-max',
+ simplicityBias: simplicityBiasEl ? simplicityBiasEl.value : 'tiebreaker'
+ },
+ allSolutions: jobData['all-solutions'],
+ scoreHistory: scoreHistory,
+ elapsedMs: elapsedMs
+ };
+ jobHistory.unshift(job);
+ renderJobHistory();
+}
+
+// Save stopped job to history (from progress data)
+function saveStoppedToHistory(progressData, scoreHistory = [], parentId = null, elapsedMs = null) {
+ if (!progressData) return;
+
+ const scoringMethodEl = document.getElementById('scoring-method');
+ const simplicityBiasEl = document.getElementById('simplicity-bias');
+ const job = {
+ id: currentJobId,
+ parentId: parentId, // Track parent job for hierarchy
+ timestamp: new Date().toLocaleString(),
+ status: 'stopped',
+ datasetName: getSelectedDatasetName(),
+ formula: progressData['best-formula'],
+ score: progressData['best-score'],
+ scores: progressData['best-scores'] || null, // All scoring method scores (with length deduction)
+ rawScores: progressData['best-raw-scores'] || null, // Raw scores without length deduction
+ lengthDeductions: progressData['length-deductions'] || null, // Length deductions per method
+ leafCount: progressData['best-formula-leaf-count'] || null,
+ iteration: progressData['iteration'],
+ totalIterations: progressData['total-iterations'],
+ xs: [...inputXs],
+ ys: [...inputYs],
+ config: {
+ iterations: parseInt(document.getElementById('iterations').value) || 100,
+ population: parseInt(document.getElementById('population').value) || 100,
+ maxLeafs: parseInt(document.getElementById('max-leafs').value) || 40,
+ seed: document.getElementById('seed').value || null,
+ scoringMethod: progressData['scoring-method'],
+ simplicityBias: simplicityBiasEl ? simplicityBiasEl.value : 'tiebreaker'
+ },
+ allSolutions: null,
+ scoreHistory: scoreHistory,
+ elapsedMs: elapsedMs
+ };
+ jobHistory.unshift(job);
+ renderJobHistory();
+}
+
+// Generate sparkline SVG from score history
+function generateSparkline(scoreHistory, width = 60, height = 16) {
+ if (!scoreHistory || scoreHistory.length < 2) return '';
+
+ const scores = scoreHistory.map(h => h.score);
+ const minScore = Math.min(...scores);
+ const maxScore = Math.max(...scores);
+ const range = maxScore - minScore || 1;
+
+ const points = scores.map((score, i) => {
+ const x = (i / (scores.length - 1)) * width;
+ const y = height - ((score - minScore) / range) * (height - 2) - 1;
+ return `${x},${y}`;
+ }).join(' ');
+
+ return ``;
+}
+
+// Build hierarchical tree from flat job array
+function buildJobTree() {
+ // Create a map for quick lookup
+ const jobMap = new Map();
+ jobHistory.forEach((job, index) => {
+ jobMap.set(job.id, { job, index, children: [] });
+ });
+
+ // Build tree structure
+ const roots = [];
+ jobHistory.forEach((job, index) => {
+ const node = jobMap.get(job.id);
+ if (job.parentId && jobMap.has(job.parentId)) {
+ // This job has a parent in our history - add as child
+ jobMap.get(job.parentId).children.push(node);
+ } else {
+ // This is a root job (no parent or parent not in history)
+ roots.push(node);
+ }
+ });
+
+ // Sort by index (lower index = more recent due to unshift)
+ const sortByNewest = (a, b) => a.index - b.index;
+
+ // Sort roots by newest first
+ roots.sort(sortByNewest);
+
+ // Sort children recursively by newest first
+ const sortChildren = (node) => {
+ node.children.sort(sortByNewest);
+ node.children.forEach(sortChildren);
+ };
+ roots.forEach(sortChildren);
+
+ return roots;
+}
+
+// Render a single job item
+function renderJobItem(node, depth = 0) {
+ const { job, index, children } = node;
+ const maxIndentLevels = 3;
+ const indent = Math.min(depth, maxIndentLevels) * 24; // Cap indentation at 4 levels
+
+ const statusBadge = job.status === 'stopped'
+ ? 'Stopped'
+ : '';
+ const datasetBadge = job.datasetName
+ ? `${job.datasetName}`
+ : '';
+ const scoringBadge = job.config.scoringMethod
+ ? `${getScoringMethodDisplay(job.config.scoringMethod)}`
+ : '';
+ const simplicityBadge = job.config.simplicityBias && job.config.simplicityBias !== 'tiebreaker'
+ ? `Simplicity: ${getSimplicityBiasDisplay(job.config.simplicityBias)}`
+ : '';
+ const iterationInfo = job.status === 'stopped' && job.iteration
+ ? ` · Stopped at ${job.iteration}/${job.totalIterations}`
+ : '';
+ const durationInfo = job.elapsedMs ? ` · ${formatDuration(job.elapsedMs)}` : '';
+ const complexityInfo = job.leafCount ? `${job.leafCount} nodes` : 'N/A';
+ const sparkline = generateSparkline(job.scoreHistory);
+ const childBadge = children.length > 0
+ ? `${children.length} run${children.length > 1 ? 's' : ''}`
+ : '';
+ // Show label if set (with ID for direct updates)
+ const labelBadge = `${job.label ? escapeHistoryHtml(job.label) : ''}`;
+ // Show depth indicator for nested jobs
+ const depthBadge = depth > 0
+ ? `L${depth}`
+ : '';
+
+ // Compare score with parent job using child's scoring method (higher score = better)
+ // Uses raw scores (without length deduction) for fair comparison across different simplicity bias settings
+ let improvementBadge = '';
+ if (job.parentId) {
+ const parentJob = jobHistory.find(j => j.id === job.parentId);
+ if (parentJob) {
+ const childMethod = job.scoringMethod || job.config?.scoringMethod || 'mae-max';
+
+ // Get child's raw score for its method (prefer raw scores for fair comparison)
+ const childRawScores = job.rawScores || job.scores;
+ const childScore = childRawScores && childRawScores[childMethod] !== undefined
+ ? childRawScores[childMethod]
+ : job.score;
+
+ // Get parent's raw score for the same method
+ const parentRawScores = parentJob.rawScores || parentJob.scores;
+ const parentScore = parentRawScores && parentRawScores[childMethod] !== undefined
+ ? parentRawScores[childMethod]
+ : (childMethod === (parentJob.scoringMethod || parentJob.config?.scoringMethod) ? parentJob.score : null);
+
+ if (childScore !== undefined && parentScore !== null && parentScore !== undefined) {
+ const diff = childScore - parentScore; // positive = improved (higher is better)
+ const pctChange = parentScore !== 0 ? (diff / Math.abs(parentScore)) * 100 : 0;
+ if (diff > 0) {
+ const arrow = '↑';
+ const displayPct = Math.abs(pctChange).toFixed(1);
+ improvementBadge = `${arrow}${displayPct}%`;
+ } else if (diff < 0) {
+ const arrow = '↓';
+ const displayPct = Math.abs(pctChange).toFixed(1);
+ improvementBadge = `${arrow}${displayPct}%`;
+ } else {
+ improvementBadge = `=`;
+ }
+ }
+ }
+ }
+
+ // Border colors for different nesting levels
+ const depthBorderColors = [
+ '', // depth 0: no border
+ 'border-purple-500/30', // depth 1: purple
+ 'border-blue-500/30', // depth 2: blue
+ 'border-teal-500/30', // depth 3: teal
+ 'border-yellow-500/30', // depth 4: yellow
+ 'border-amber-500/30', // depth 5: amber
+ 'border-orange-500/30', // depth 6: orange
+ 'border-red-500/30', // depth 7+: red
+ ];
+ const borderColor = depth > 0 ? depthBorderColors[Math.min(depth, depthBorderColors.length - 1)] : '';
+
+ // Check if this tree is collapsed
+ const isTreeCollapsed = collapsedTrees.has(job.id);
+ const hasChildren = children.length > 0;
+
+ // Tree expand/collapse icon (only shown if has children)
+ const treeToggleIcon = hasChildren ? `
+
+ ` : ``; // Spacer for alignment when no children
+
+ // Render the job
+ let html = `
+
+
+
+ ${treeToggleIcon}
+
+
+
+ ${labelBadge}
+ ${depthBadge}
+ ${improvementBadge}
+ ${statusBadge}
+ ${scoringBadge}
+ ${simplicityBadge}
+ ${datasetBadge}
+ ${childBadge}
+
+
+ ${formatCompactHistoryScores(job)}
+ ·
+ ${job.xs.length} pts${durationInfo}${iterationInfo}
+ ·
+ ${job.timestamp}
+ ${sparkline}
+
+
+
+
+
+ ${children.length > 0 ? `
+
+ ` : ''}
+
+
+
+
+
+ ${createHistoryEditableLabelHtml(index, job.label)}
+ ${formatHistoryScores(job)}
+
+
+ Complexity:
+ ${complexityInfo}
+
+
+ Iterations:
+ ${job.status === 'stopped' ? `${job.iteration}/${job.totalIterations}` : job.config.iterations}
+
+
+ Population:
+ ${job.config.population}
+
+ ${job.config.seed ? `
+ Seed:
+ ${job.config.seed}
+
` : ''}
+
+
+
+
Formula:
+
+
${job.formula}
+
+
+
+
+
+
+
Input Data (${job.xs.length} points):
+
+
X: ${job.xs.map(x => x.toFixed(4)).join(', ')}
+
Y: ${job.ys.map(y => y.toFixed(4)).join(', ')}
+
+
+
+
+
+
Formula Fit Chart
+
+
+
+
+
+
+
`;
+
+ // Render children (if not collapsed)
+ if (!isTreeCollapsed) {
+ children.forEach(child => {
+ html += renderJobItem(child, depth + 1);
+ });
+ }
+
+ return html;
+}
+
+// Render job history list
+function renderJobHistory() {
+ const section = document.getElementById('job-history-section');
+ const container = document.getElementById('job-history');
+
+ if (jobHistory.length === 0) {
+ section.classList.add('hidden');
+ return;
+ }
+
+ section.classList.remove('hidden');
+
+ // Build hierarchical tree and render
+ const tree = buildJobTree();
+ container.innerHTML = tree.map(node => renderJobItem(node, 0)).join('');
+}
+
+// Toggle charts visibility within a history item
+function toggleHistoryCharts(index) {
+ const chartsContainer = document.getElementById(`history-charts-${index}`);
+ const chartsChevron = document.getElementById(`charts-chevron-${index}`);
+
+ if (!chartsContainer) return;
+
+ const isHidden = chartsContainer.classList.contains('hidden');
+
+ if (isHidden) {
+ chartsContainer.classList.remove('hidden');
+ chartsChevron.classList.add('rotate-90');
+ // Render fit chart after showing container
+ // Use requestAnimationFrame to ensure browser has completed layout
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ renderHistoryChart(index);
+ // Force resize to ensure proper rendering
+ if (historyCharts[index]) {
+ historyCharts[index].resize();
+ }
+ });
+ });
+ } else {
+ chartsContainer.classList.add('hidden');
+ chartsChevron.classList.remove('rotate-90');
+ // Dispose chart
+ if (historyCharts[index]) {
+ historyCharts[index].dispose();
+ delete historyCharts[index];
+ }
+ }
+}
+
+// Toggle history item expansion
+function toggleHistoryItem(index) {
+ const details = document.getElementById(`history-details-${index}`);
+ const chevron = document.getElementById(`chevron-${index}`);
+ const isHidden = details.classList.contains('hidden');
+ const job = jobHistory[index];
+
+ if (isHidden) {
+ details.classList.remove('hidden');
+ chevron.classList.add('rotate-90');
+ // Render LaTeX formula when expanded
+ if (job && job.formula) {
+ renderLatex(`history-latex-${index}`, job.formula);
+ }
+ } else {
+ details.classList.add('hidden');
+ chevron.classList.remove('rotate-90');
+ // Also collapse charts section and dispose chart
+ const chartsContainer = document.getElementById(`history-charts-${index}`);
+ const chartsChevron = document.getElementById(`charts-chevron-${index}`);
+ if (chartsContainer) {
+ chartsContainer.classList.add('hidden');
+ if (chartsChevron) chartsChevron.classList.remove('rotate-90');
+ }
+ if (historyCharts[index]) {
+ historyCharts[index].dispose();
+ delete historyCharts[index];
+ }
+ }
+}
+
+// Remove job from history
+function removeFromHistory(index) {
+ const job = jobHistory[index];
+ if (!job) return;
+
+ // Dispose chart if it exists
+ if (historyCharts[index]) {
+ historyCharts[index].dispose();
+ delete historyCharts[index];
+ }
+
+ // Re-parent direct children to the deleted job's parent (preserves tree structure)
+ const deletedJobId = job.id;
+ const deletedJobParentId = job.parentId;
+ jobHistory.forEach(j => {
+ if (j.parentId === deletedJobId) {
+ j.parentId = deletedJobParentId;
+ }
+ });
+
+ // Remove from array
+ jobHistory.splice(index, 1);
+ // Re-render (this will update all indices)
+ renderJobHistory();
+}
+
+// Get all descendant job IDs for a given job
+function getDescendantJobIds(jobId) {
+ const descendants = [];
+ const findChildren = (parentId) => {
+ jobHistory.forEach(job => {
+ if (job.parentId === parentId) {
+ descendants.push(job.id);
+ findChildren(job.id); // Recursively find children of children
+ }
+ });
+ };
+ findChildren(jobId);
+ return descendants;
+}
+
+// Remove job and all its descendants from history
+function removeWithChildrenFromHistory(index) {
+ const job = jobHistory[index];
+ if (!job) return;
+
+ // Get all descendant IDs
+ const descendantIds = getDescendantJobIds(job.id);
+ const idsToRemove = new Set([job.id, ...descendantIds]);
+
+ // Dispose charts for all items being removed
+ jobHistory.forEach((j, idx) => {
+ if (idsToRemove.has(j.id)) {
+ if (historyCharts[idx]) {
+ historyCharts[idx].dispose();
+ delete historyCharts[idx];
+ }
+ }
+ });
+
+ // Also remove from collapsed set
+ idsToRemove.forEach(id => collapsedTrees.delete(id));
+
+ // Filter out all jobs to remove
+ jobHistory = jobHistory.filter(j => !idsToRemove.has(j.id));
+
+ // Re-render
+ renderJobHistory();
+}
+
+// Toggle tree collapse state for a job
+function toggleTreeCollapse(jobId, event) {
+ event.stopPropagation();
+ if (collapsedTrees.has(jobId)) {
+ collapsedTrees.delete(jobId);
+ } else {
+ collapsedTrees.add(jobId);
+ }
+ renderJobHistory();
+}
+
+// Load data from history item
+function loadFromHistory(index) {
+ const job = jobHistory[index];
+ if (!job) return;
+
+ document.getElementById('xs').value = job.xs.join(', ');
+ document.getElementById('ys').value = job.ys.join(', ');
+ document.getElementById('iterations').value = job.config.iterations;
+ document.getElementById('population').value = job.config.population;
+ document.getElementById('max-leafs').value = job.config.maxLeafs;
+ document.getElementById('seed').value = job.config.seed || '';
+
+ // Restore scoring method if available
+ const scoringMethodEl = document.getElementById('scoring-method');
+ if (scoringMethodEl && job.config.scoringMethod) {
+ scoringMethodEl.value = job.config.scoringMethod;
+ }
+
+ // Restore simplicity bias if available
+ const simplicityBiasEl = document.getElementById('simplicity-bias');
+ if (simplicityBiasEl && job.config.simplicityBias) {
+ simplicityBiasEl.value = job.config.simplicityBias;
+ }
+
+ initDataEditorChart();
+
+ document.getElementById('preset-select').value = '';
+ document.getElementById('points-container').classList.add('hidden');
+
+ window.scrollTo({ top: 0, behavior: 'smooth' });
+}
+
+// Rerun - continue evolution using job's original config and data
+async function rerunFromHistory(index) {
+ const job = jobHistory[index];
+ if (!job) return;
+
+ // Get formulas from the job
+ let seedFormulas = [];
+ if (job.allSolutions && job.allSolutions.length > 0) {
+ seedFormulas = job.allSolutions.map(s => s.formula);
+ } else if (job.formula) {
+ seedFormulas = [job.formula];
+ }
+
+ if (seedFormulas.length === 0) {
+ alert('No formulas available to seed from this job');
+ return;
+ }
+
+ // Use the job's original job ID to call continue endpoint
+ const sourceJobId = job.id;
+
+ // Use job's stored config (not UI form values)
+ const config = {
+ iterations: job.config.iterations,
+ population: job.config.population,
+ maxLeafs: job.config.maxLeafs,
+ scoringMethod: job.config.scoringMethod,
+ simplicityBias: job.config.simplicityBias || 'tiebreaker',
+ adaptiveMode: job.config.adaptiveMode || false,
+ useEvalCache: job.config.useEvalCache || false,
+ quietLogs: job.config.quietLogs !== false, // Default to true
+ };
+
+ // Include mutations blacklist if it was stored
+ if (job.config.mutationsBlacklist && job.config.mutationsBlacklist.length > 0) {
+ config.mutationsBlacklist = job.config.mutationsBlacklist;
+ }
+
+ // Include seed if it was stored
+ if (job.config.seed) {
+ config.seed = job.config.seed;
+ }
+
+ console.debug('Rerunning job with job config: ', sourceJobId, config);
+
+ try {
+ // Use startNewJob which handles tab creation
+ startNewJob(job.xs, job.ys, config, job.datasetName, sourceJobId);
+
+ // Scroll to top to see progress
+ window.scrollTo({ top: 0, behavior: 'smooth' });
+
+ } catch (error) {
+ console.error('Failed to start Rerun job:', error);
+ alert('Failed to rerun: ' + error.message);
+ }
+}
+
+// Run From New Config - continue evolution using UI form config but job's formulas/data
+async function keepGoingFromHistory(index) {
+ const job = jobHistory[index];
+ if (!job) return;
+
+ // Get formulas from the job
+ let seedFormulas = [];
+ if (job.allSolutions && job.allSolutions.length > 0) {
+ seedFormulas = job.allSolutions.map(s => s.formula);
+ } else if (job.formula) {
+ seedFormulas = [job.formula];
+ }
+
+ if (seedFormulas.length === 0) {
+ alert('No formulas available to seed from this job');
+ return;
+ }
+
+ // Use the job's original job ID to call continue endpoint
+ const sourceJobId = job.id;
+
+ // Build config from current form values (not job's stored config)
+ const adaptiveModeEl = document.getElementById('adaptive-mode');
+ const quietLogsEl = document.getElementById('quiet-logs');
+ const evalCacheEl = document.getElementById('eval-cache');
+
+ const config = {
+ iterations: parseInt(document.getElementById('iterations').value) || job.config.iterations,
+ population: parseInt(document.getElementById('population').value) || job.config.population,
+ maxLeafs: parseInt(document.getElementById('max-leafs').value) || job.config.maxLeafs,
+ scoringMethod: document.getElementById('scoring-method')?.value || job.config.scoringMethod,
+ simplicityBias: document.getElementById('simplicity-bias')?.value || job.config.simplicityBias || 'tiebreaker',
+ adaptiveMode: adaptiveModeEl && adaptiveModeEl.getAttribute('aria-checked') === 'true',
+ useEvalCache: evalCacheEl && evalCacheEl.getAttribute('aria-checked') === 'true',
+ quietLogs: !(quietLogsEl && quietLogsEl.getAttribute('aria-checked') === 'true'),
+ };
+
+ // Add mutations blacklist if any mutations are excluded
+ const blacklist = getMutationsBlacklist();
+ if (blacklist.length > 0) {
+ config.mutationsBlacklist = blacklist;
+ }
+
+ // Add seed if specified
+ const seedInput = document.getElementById('seed').value;
+ if (seedInput) {
+ config.seed = parseInt(seedInput);
+ }
+
+ console.debug('Continuing job with new config: ', sourceJobId, config);
+
+ try {
+ // Use startNewJob which handles tab creation
+ startNewJob(job.xs, job.ys, config, job.datasetName, sourceJobId);
+
+ // Scroll to top to see progress
+ window.scrollTo({ top: 0, behavior: 'smooth' });
+
+ } catch (error) {
+ console.error('Failed to start Run From New Config job:', error);
+ alert('Failed to continue: ' + error.message);
+ }
+}
diff --git a/resources/public/js/solver-init.js b/resources/public/js/solver-init.js
new file mode 100644
index 00000000..4c6a0430
--- /dev/null
+++ b/resources/public/js/solver-init.js
@@ -0,0 +1,51 @@
+/**
+ * Solver initialization and event listeners
+ */
+
+// Debounced editor update
+const debouncedUpdateEditor = debounce(initDataEditorChart, 30);
+
+// Initialize on page load
+document.addEventListener('DOMContentLoaded', function() {
+ // Load default preset (Feynman Diffraction)
+ const presetSelect = document.getElementById('preset-select');
+ if (presetSelect) {
+ // Find and select the option by value
+ const defaultPresetId = 'feynman-diffraction';
+ for (let i = 0; i < presetSelect.options.length; i++) {
+ if (presetSelect.options[i].value === defaultPresetId) {
+ presetSelect.selectedIndex = i;
+ break;
+ }
+ }
+ loadPreset(defaultPresetId);
+ } else {
+ // Fallback: just initialize the chart with whatever data is in the textareas
+ initDataEditorChart();
+ }
+
+ // Listen for changes to X and Y textareas
+ document.getElementById('xs').addEventListener('input', debouncedUpdateEditor);
+ document.getElementById('ys').addEventListener('input', debouncedUpdateEditor);
+
+ // Clear dataset name when user manually edits the data
+ document.getElementById('xs').addEventListener('input', clearDatasetName);
+ document.getElementById('ys').addEventListener('input', clearDatasetName);
+
+ // Form submission
+ document.getElementById('solver-form').addEventListener('submit', submitSolverForm);
+
+ // Handle window resize for charts
+ window.addEventListener('resize', function() {
+ if (fitChart) {
+ fitChart.resize();
+ }
+ if (dataEditorChart) {
+ dataEditorChart.resize();
+ setupDragHandlers();
+ }
+ });
+
+ // Note: beforeunload handler for stopping all jobs is in solver-job.js
+ // where it has access to activeJobs state
+});
diff --git a/resources/public/js/solver-job.js b/resources/public/js/solver-job.js
new file mode 100644
index 00000000..06d4ac38
--- /dev/null
+++ b/resources/public/js/solver-job.js
@@ -0,0 +1,1379 @@
+/**
+ * Job control (start, stop, pause, SSE) with multi-job support
+ */
+
+// Multi-job state management
+// Map: jobId -> { eventSource, isPaused, datasetName, startTime, scoreHistory, inputXs, inputYs, progress, status }
+let activeJobs = new Map();
+let selectedJobId = null;
+let jobCounter = 0; // For naming jobs when no dataset name
+
+// Legacy compatibility - these are now derived from activeJobs for the selected job
+// Input data storage for form submissions
+let inputXs = [];
+let inputYs = [];
+
+// Format seconds into human-readable time string
+function formatETA(seconds) {
+ if (seconds < 0 || !isFinite(seconds)) return '--';
+
+ const hours = Math.floor(seconds / 3600);
+ const minutes = Math.floor((seconds % 3600) / 60);
+ const secs = Math.floor(seconds % 60);
+
+ if (hours > 0) {
+ return `${hours}h ${minutes}m`;
+ } else if (minutes > 0) {
+ return `${minutes}m ${secs}s`;
+ } else {
+ return `${secs}s`;
+ }
+}
+
+// Format all scores for a solution, highlighting the primary scoring method - with sparklines
+// Shows raw scores (without length deduction) for fair comparison, with deduction shown separately
+function formatAllScores(solution, primaryMethod, scoreHistory) {
+ const scores = solution.scores;
+ const rawScores = solution.rawScores;
+ const lengthDeductions = solution.lengthDeductions;
+
+ if (!scores) {
+ // Fallback for solutions without multi-score data
+ return `
+ Score:
+ ${solution.score.toFixed(6)}
+
`;
+ }
+
+ const methods = [
+ { key: 'mae-max', name: 'MAE + Max' },
+ { key: 'log-cosh', name: 'Log-Cosh' },
+ { key: 'r-squared', name: 'R²' }
+ ];
+
+ // Use raw scores for display if available, fall back to adjusted scores
+ const displayScores = rawScores || scores;
+
+ return `
+ ${methods.map(m => {
+ const isPrimary = m.key === primaryMethod;
+ const score = displayScores[m.key];
+ const deduction = lengthDeductions ? lengthDeductions[m.key] : 0;
+ const scoreStr = (score !== undefined && score !== null) ? score.toFixed(6) : '--';
+ const sparkline = generateMethodSparkline(scoreHistory, m.key);
+ const deductionStr = formatLengthDeduction(deduction);
+ return `
+
${m.name}${isPrimary ? ' *' : ''}
+
${scoreStr}
+ ${deductionStr}
+ ${sparkline}
+
`;
+ }).join('')}
+
+ * Scoring method used for optimization
`;
+}
+
+// Format scores in a compact single-line format for other solutions
+// Shows raw scores (without length deduction) for fair comparison
+function formatCompactScores(solution, primaryMethod) {
+ const scores = solution.scores;
+ const rawScores = solution.rawScores;
+ const lengthDeductions = solution.lengthDeductions;
+
+ if (!scores) {
+ return `Score: ${solution.score.toFixed(6)}
`;
+ }
+
+ const methods = [
+ { key: 'mae-max', name: 'MAE' },
+ { key: 'log-cosh', name: 'LC' },
+ { key: 'r-squared', name: 'R²' }
+ ];
+
+ // Use raw scores for display if available
+ const displayScores = rawScores || scores;
+
+ // Check if there's any non-zero deduction for the primary method
+ const primaryDeduction = lengthDeductions ? lengthDeductions[primaryMethod] : 0;
+ const hasDeduction = primaryDeduction && primaryDeduction > 0;
+
+ return `
+ ${methods.map(m => {
+ const isPrimary = m.key === primaryMethod;
+ const score = displayScores[m.key];
+ const scoreStr = (score !== undefined && score !== null) ? score.toFixed(4) : '--';
+ return `
+ ${m.name}: ${scoreStr}${isPrimary ? '*' : ''}
+ `;
+ }).join('')}
+ ${hasDeduction ? `(−${primaryDeduction.toFixed(4)} bias)` : ''}
+
`;
+}
+
+// Format starting score comparison for jobs with a parent
+// Uses raw scores (without length deduction) for fair comparison
+function formatStartingScoreComparison(job, data) {
+ const currentMethod = data['scoring-method'] || 'mae-max';
+ // Prefer raw scores for comparison, fall back to adjusted scores
+ const currentRawScores = data['best-raw-scores'] || data['best-scores'];
+
+ // Get the current raw score for the current job's scoring method
+ const currentScore = currentRawScores && currentRawScores[currentMethod] !== undefined
+ ? currentRawScores[currentMethod]
+ : data['best-score'];
+
+ // Get the starting raw score for the same method from the parent job
+ // Prefer raw scores for fair comparison across different simplicity bias settings
+ let startScore = null;
+ if (job.startingRawScores && job.startingRawScores[currentMethod] !== undefined) {
+ startScore = job.startingRawScores[currentMethod];
+ } else if (job.startingScores && job.startingScores[currentMethod] !== undefined) {
+ // Fallback to adjusted scores if raw not available
+ startScore = job.startingScores[currentMethod];
+ } else if (job.startingScore !== null && job.startingScore !== undefined) {
+ // Fallback to primary score if method-specific score not available
+ startScore = job.startingScore;
+ }
+
+ if (startScore === null || startScore === undefined) return '';
+
+ const diff = startScore - currentScore;
+ const pctChange = startScore !== 0 ? (diff / Math.abs(startScore)) * 100 : 0;
+
+ let changeIndicator = '';
+ if (diff < 0) {
+ // Score increased (improved - higher is better)
+ changeIndicator = `(improved ${Math.abs(pctChange).toFixed(1)}%)`;
+ } else if (diff > 0) {
+ changeIndicator = `(worsened ${Math.abs(pctChange).toFixed(1)}%)`;
+ }
+
+ const methodDisplay = getScoringMethodDisplay(currentMethod);
+
+ return `
+ Starting ${methodDisplay}:
+ ${startScore.toFixed(6)}
+ →
+ Current:
+ ${currentScore.toFixed(6)}
+ ${changeIndicator}
+
`;
+}
+
+// Format length deduction display
+function formatLengthDeduction(deduction) {
+ if (!deduction || deduction === 0) return '';
+ return `−${deduction.toFixed(6)} bias
`;
+}
+
+// Format scores for progress display (during job run) - with sparklines
+// Shows raw scores (without length deduction) for fair comparison, with deduction shown separately
+function formatProgressScores(data, scoreHistory) {
+ const scores = data['best-scores'];
+ const rawScores = data['best-raw-scores'];
+ const lengthDeductions = data['length-deductions'];
+ const primaryMethod = data['scoring-method'] || 'mae-max';
+
+ if (!scores) {
+ // Fallback for progress without multi-score data
+ return `
+ Score:
+ ${data['best-score'].toFixed(6)}
+
`;
+ }
+
+ const methods = [
+ { key: 'mae-max', name: 'MAE + Max' },
+ { key: 'log-cosh', name: 'Log-Cosh' },
+ { key: 'r-squared', name: 'R²' }
+ ];
+
+ // Use raw scores for display if available, fall back to adjusted scores
+ const displayScores = rawScores || scores;
+
+ return `
+ ${methods.map(m => {
+ const isPrimary = m.key === primaryMethod;
+ const score = displayScores[m.key];
+ const deduction = lengthDeductions ? lengthDeductions[m.key] : 0;
+ const scoreStr = (score !== undefined && score !== null) ? score.toFixed(6) : '--';
+ const sparkline = generateMethodSparkline(scoreHistory, m.key);
+ const deductionStr = formatLengthDeduction(deduction);
+ return `
+
${m.name}${isPrimary ? ' *' : ''}
+
${scoreStr}
+ ${deductionStr}
+ ${sparkline}
+
`;
+ }).join('')}
+
`;
+}
+
+// ============================================================================
+// Tab Management
+// ============================================================================
+
+const MAX_VISIBLE_TABS = 2;
+
+// Get ordered list of job IDs (most recent first based on startTime)
+function getOrderedJobIds() {
+ return Array.from(activeJobs.entries())
+ .sort((a, b) => b[1].startTime - a[1].startTime)
+ .map(([jobId]) => jobId);
+}
+
+// Generate short display name for a job
+function getJobDisplayName(job, short = false) {
+ // Strip formula portion from dataset name (e.g., "Feynman Diffraction (sin²(5x/2)...)" -> "Feynman Diffraction")
+ const cleanDatasetName = job.datasetName ? job.datasetName.split(' (')[0] : null;
+
+ if (short) {
+ // Very short version for tabs: just number or abbreviated dataset
+ if (cleanDatasetName) {
+ // Take first 6 chars of dataset name
+ const abbrev = cleanDatasetName.length > 6 ? cleanDatasetName.slice(0, 6) : cleanDatasetName;
+ return `${abbrev}#${job.sequenceNum}`;
+ }
+ return `#${job.sequenceNum}`;
+ }
+ // Full label format: "Job #N" or "Job #N - DatasetName"
+ if (cleanDatasetName) {
+ return `Job #${job.sequenceNum} - ${cleanDatasetName}`;
+ }
+ return `Job #${job.sequenceNum}`;
+}
+
+// Update job label for an active job
+function updateJobLabel(jobId, newLabel) {
+ const job = activeJobs.get(jobId);
+ if (job) {
+ job.label = newLabel.trim() || getJobDisplayName(job);
+ renderTabs(); // Update tab display
+ }
+}
+
+// Create editable label HTML for a job
+function createEditableLabelHtml(jobId, label, inputId) {
+ return `
+
+ Label:
+
+
+ `;
+}
+
+// Escape HTML special characters
+function escapeHtml(text) {
+ if (!text) return '';
+ const div = document.createElement('div');
+ div.textContent = text;
+ return div.innerHTML;
+}
+
+// Render all tabs (called when jobs change)
+function renderTabs() {
+ const tabsList = document.getElementById('job-tabs-list');
+ const tabsContainer = document.getElementById('job-tabs');
+
+ // Close any open overflow menu when re-rendering
+ closeTabOverflowMenu();
+
+ if (activeJobs.size === 0) {
+ tabsContainer.classList.add('hidden');
+ return;
+ }
+
+ tabsContainer.classList.remove('hidden');
+ tabsList.innerHTML = '';
+
+ const orderedJobIds = getOrderedJobIds();
+
+ // Ensure selected job is always in visible tabs (first position)
+ let visibleJobIds = [];
+ let overflowJobIds = [];
+
+ if (selectedJobId && activeJobs.has(selectedJobId)) {
+ // Selected job goes first
+ visibleJobIds.push(selectedJobId);
+ // Fill remaining visible slots with other jobs (in order)
+ const otherJobs = orderedJobIds.filter(id => id !== selectedJobId);
+ visibleJobIds = visibleJobIds.concat(otherJobs.slice(0, MAX_VISIBLE_TABS - 1));
+ overflowJobIds = otherJobs.slice(MAX_VISIBLE_TABS - 1);
+ } else {
+ visibleJobIds = orderedJobIds.slice(0, MAX_VISIBLE_TABS);
+ overflowJobIds = orderedJobIds.slice(MAX_VISIBLE_TABS);
+ }
+
+ // Render visible tabs
+ visibleJobIds.forEach(jobId => {
+ const job = activeJobs.get(jobId);
+ tabsList.appendChild(createTabElement(jobId, job));
+ });
+
+ // Render overflow dropdown if needed
+ if (overflowJobIds.length > 0) {
+ tabsList.appendChild(createOverflowDropdown(overflowJobIds));
+ }
+}
+
+// Create a single tab element
+function createTabElement(jobId, job) {
+ const tabDiv = document.createElement('div');
+ tabDiv.id = `job-tab-${jobId}`;
+ tabDiv.className = 'flex-1 px-2 py-1 text-xs rounded-t-md border-b-2 transition-colors flex items-center justify-between whitespace-nowrap cursor-pointer';
+
+ // Apply selected styling
+ if (selectedJobId === jobId) {
+ tabDiv.classList.add('bg-gray-700', 'border-blue-500', 'text-white');
+ } else {
+ tabDiv.classList.add('bg-gray-800', 'border-transparent', 'text-gray-400', 'hover:text-gray-300');
+ }
+
+ tabDiv.onclick = (e) => {
+ if (!e.target.closest('.tab-control')) {
+ selectJobTab(jobId);
+ }
+ };
+
+ const displayName = job.label || getJobDisplayName(job, true);
+ const pauseHidden = job.isPaused ? 'hidden' : '';
+ const resumeHidden = job.isPaused ? '' : 'hidden';
+
+ tabDiv.innerHTML = `
+
+
+ ${escapeHtml(displayName)}
+
+
+
+
+
+ `;
+
+ return tabDiv;
+}
+
+// Create overflow dropdown for additional tabs
+function createOverflowDropdown(overflowJobIds) {
+ const dropdown = document.createElement('div');
+ dropdown.className = 'relative';
+ dropdown.id = 'tab-overflow-dropdown';
+
+ dropdown.innerHTML = `
+
+ `;
+
+ // Store overflow job IDs for menu generation
+ dropdown.dataset.jobIds = JSON.stringify(overflowJobIds);
+
+ return dropdown;
+}
+
+function toggleTabOverflowMenu(event) {
+ event.stopPropagation();
+
+ let menu = document.getElementById('tab-overflow-menu');
+
+ if (menu) {
+ // Toggle existing menu
+ menu.remove();
+ return;
+ }
+
+ // Get the button position
+ const btn = document.getElementById('tab-overflow-btn');
+ const dropdown = document.getElementById('tab-overflow-dropdown');
+ if (!btn || !dropdown) return;
+
+ const rect = btn.getBoundingClientRect();
+ const overflowJobIds = JSON.parse(dropdown.dataset.jobIds || '[]');
+
+ // Create fixed-position menu
+ menu = document.createElement('div');
+ menu.id = 'tab-overflow-menu';
+ menu.className = 'fixed bg-gray-800 border border-gray-700 rounded-md shadow-lg min-w-48';
+ menu.style.cssText = `top: ${rect.bottom + 4}px; left: ${rect.left}px; z-index: 9999;`;
+
+ menu.innerHTML = overflowJobIds.map(jobId => {
+ const job = activeJobs.get(jobId);
+ if (!job) return '';
+ const displayName = job.label || getJobDisplayName(job);
+ const pauseHidden = job.isPaused ? 'hidden' : '';
+ const resumeHidden = job.isPaused ? '' : 'hidden';
+ return `
+
+
+
+
${escapeHtml(displayName)}
+
+
+
+
+
+
+ `;
+ }).join('');
+
+ document.body.appendChild(menu);
+}
+
+function closeTabOverflowMenu() {
+ const menu = document.getElementById('tab-overflow-menu');
+ if (menu) {
+ menu.remove();
+ }
+}
+
+// Close dropdown when clicking outside
+document.addEventListener('click', (e) => {
+ const menu = document.getElementById('tab-overflow-menu');
+ const btn = document.getElementById('tab-overflow-btn');
+ if (menu && btn && !menu.contains(e.target) && !btn.contains(e.target)) {
+ closeTabOverflowMenu();
+ }
+});
+
+function createJobTab(jobId, name) {
+ // Just re-render all tabs (this handles ordering and overflow)
+ renderTabs();
+ selectJobTab(jobId);
+}
+
+// Toggle pause for a specific job (called from tab control)
+async function togglePauseForJob(jobId) {
+ const job = activeJobs.get(jobId);
+ if (!job) return;
+
+ const endpoint = job.isPaused ? 'resume' : 'pause';
+
+ try {
+ const response = await fetch('/api/jobs/' + jobId + '/' + endpoint, {
+ method: 'POST'
+ });
+ const data = await response.json();
+
+ if (data.error) {
+ console.error('Error toggling pause:', data.error);
+ return;
+ }
+
+ job.isPaused = !job.isPaused;
+ updateTabPauseIcon(jobId, job.isPaused);
+
+ // Also update main pause button if this is the selected job
+ if (selectedJobId === jobId) {
+ updatePauseButtonForJob(job.isPaused);
+ }
+ } catch (err) {
+ console.error('Error toggling pause:', err);
+ }
+}
+
+// Stop a specific job by ID (called from tab control)
+async function stopJobById(jobId) {
+ try {
+ const response = await fetch('/api/jobs/' + jobId + '/stop', {
+ method: 'POST'
+ });
+ const data = await response.json();
+
+ if (data.error) {
+ console.error('Error stopping job:', data.error);
+ }
+ } catch (err) {
+ console.error('Error stopping job:', err);
+ }
+}
+
+// Update the pause/resume icon in a tab
+function updateTabPauseIcon(jobId, isPaused) {
+ // Re-render tabs to update pause icon state
+ renderTabs();
+}
+
+function selectJobTab(jobId) {
+ if (!activeJobs.has(jobId)) return;
+
+ selectedJobId = jobId;
+
+ // Re-render tabs to update styling and possibly move selected to visible area
+ renderTabs();
+
+ // Update pause button state for selected job
+ const job = activeJobs.get(jobId);
+ if (job) {
+ updatePauseButtonForJob(job.isPaused);
+ }
+
+ // Re-render content for selected job
+ renderJobContent(jobId);
+}
+
+function closeJobTab(jobId) {
+ // Clean up job resources
+ const job = activeJobs.get(jobId);
+ if (job) {
+ if (job.eventSource) {
+ job.eventSource.close();
+ }
+ activeJobs.delete(jobId);
+ }
+
+ // Re-render tabs
+ renderTabs();
+
+ // Handle selection if no more jobs or current was closed
+ if (activeJobs.size === 0) {
+ selectedJobId = null;
+ showDefaultResults();
+ hideJobControls();
+ } else if (selectedJobId === jobId) {
+ // Select the most recent job
+ const nextJobId = getOrderedJobIds()[0];
+ selectJobTab(nextJobId);
+ }
+}
+
+function renderJobContent(jobId) {
+ const job = activeJobs.get(jobId);
+ if (!job) return;
+
+ const resultsDiv = document.getElementById('results');
+
+ // Dispose existing chart before re-rendering (prevents stale reference when switching tabs)
+ disposeFitChart();
+
+ if (job.progress) {
+ // Show progress
+ renderProgressContent(jobId, job);
+ } else {
+ // Waiting for first update
+ const datasetBadge = job.datasetName
+ ? `${job.datasetName}`
+ : '';
+
+ resultsDiv.innerHTML = `
+
+
+
Solver running...
+ ${datasetBadge}
+
+
+ Waiting for first update...
+
+
+
+ `;
+ }
+}
+
+function renderProgressContent(jobId, job) {
+ const resultsDiv = document.getElementById('results');
+ const data = job.progress;
+
+ const percent = Math.round((data.iteration / data['total-iterations']) * 100);
+
+ // Calculate elapsed time and ETA
+ const elapsedMs = Date.now() - job.startTime;
+ const elapsedDisplay = formatETA(elapsedMs / 1000);
+ const iterationsCompleted = data.iteration;
+ const iterationsRemaining = data['total-iterations'] - iterationsCompleted;
+ let etaDisplay = '--';
+ if (iterationsCompleted > 0) {
+ const msPerIteration = elapsedMs / iterationsCompleted;
+ const remainingMs = msPerIteration * iterationsRemaining;
+ etaDisplay = formatETA(remainingMs / 1000);
+ }
+
+ const datasetBadge = job.datasetName
+ ? `${job.datasetName}`
+ : '';
+
+ // Check if we need to do a full render or just update dynamic parts
+ const existingDynamic = document.getElementById('progress-dynamic-content');
+
+ if (!existingDynamic) {
+ // First render - create full structure with static label section
+ disposeFitChart();
+
+ resultsDiv.innerHTML = `
+
+
+
Solver running...
+ ${datasetBadge}
+
+ ${createEditableLabelHtml(jobId, job.label, 'job-label-input')}
+
+
+
+ `;
+ }
+
+ // Update only the dynamic content (progress bar, formula, scores)
+ const dynamicContent = document.getElementById('progress-dynamic-content');
+ if (dynamicContent) {
+ dynamicContent.innerHTML = `
+
+
+
+ Progress
+
+ Elapsed: ${elapsedDisplay}
+ ETA: ${etaDisplay}
+ ${data.iteration} / ${data['total-iterations']}
+
+
+
+
+
+
Current Best Formula:
+
+
${data['best-formula']}
+
+
+
+
+
+ Complexity:
+ ${data['best-formula-leaf-count']} nodes
+
+
+ Optimizing:
+ ${getScoringMethodDisplay(data['scoring-method'])}
+
+
+ Simplicity Bias:
+ ${getSimplicityBiasDisplay(data['simplicity-bias'])}
+
+
+ ${job.startingScore !== null ? formatStartingScoreComparison(job, data) : ''}
+ ${formatProgressScores(data, job.scoreHistory)}
+
+ `;
+ }
+
+ // Render charts and latex
+ renderFitChart(data['best-formula'], false, job.inputXs, job.inputYs);
+ renderLatex('formula-latex', data['best-formula']);
+}
+
+// Store last completed job for display
+let lastCompletedJob = null;
+
+function showDefaultResults() {
+ const resultsDiv = document.getElementById('results');
+
+ // If we have a last completed/stopped job, show its results instead of default message
+ if (lastCompletedJob) {
+ if (lastCompletedJob.status === 'stopped') {
+ showStoppedJobResults(lastCompletedJob);
+ } else {
+ showCompletedJobResults(lastCompletedJob);
+ }
+ return;
+ }
+
+ resultsDiv.innerHTML = `
+ Enter your data and click "Find Formula" to start the solver.
+
+ The genetic algorithm will evolve mathematical expressions to find
+ the best fit for your data.
+
+ `;
+}
+
+function showCompletedJobResults(job) {
+ const resultsDiv = document.getElementById('results');
+ const { data, scoreHistory, inputXs, inputYs, datasetName, label } = job;
+
+ const datasetBadge = datasetName
+ ? `${datasetName}`
+ : '';
+
+ const labelDisplay = label
+ ? `Label: ${escapeHtml(label)}
`
+ : '';
+
+ disposeFitChart();
+
+ resultsDiv.innerHTML = `
+
+
+
+
Completed!
+ ${datasetBadge}
+
+ ${labelDisplay}
+
+
+
Best Formula Found:
+
+
${data['best-solution'].formula}
+
+
+
+
+
+
+
+ Complexity:
+ ${data['best-solution'].leafCount} nodes
+
+
+ Optimized for:
+ ${getScoringMethodDisplay(data['scoring-method'])}
+
+
+ Simplicity bias:
+ ${getSimplicityBiasDisplay(data['simplicity-bias'])}
+
+
+ ${formatAllScores(data['best-solution'], data['scoring-method'], scoreHistory)}
+
+
+
+
+
Other Solutions:
+
+ ${data['all-solutions'].slice(1, 6).map((sol, i) => `
+
+
${sol.formula}
+ ${formatCompactScores(sol, data['scoring-method'])}
+
+ `).join('')}
+
+
+
+ `;
+
+ renderFitChart(data['best-solution'].formula, true, inputXs, inputYs);
+ renderLatex('formula-latex', data['best-solution'].formula);
+}
+
+function showStoppedJobResults(job) {
+ const resultsDiv = document.getElementById('results');
+ const { progressData, scoreHistory, inputXs, inputYs, config, datasetName, label } = job;
+
+ const datasetBadge = datasetName
+ ? `${datasetName}`
+ : '';
+
+ const labelDisplay = label
+ ? `Label: ${escapeHtml(label)}
`
+ : '';
+
+ const scoringMethod = progressData['scoring-method'] || config?.scoringMethod || 'mae-max';
+ const simplicityBias = progressData['simplicity-bias'] || config?.scoringMethod || 'tiebreaker';
+ const formula = progressData['best-formula'] || 'N/A';
+ const leafCount = progressData['best-formula-leaf-count'] || 'N/A';
+ const iteration = progressData['iteration'] || 0;
+ const totalIterations = progressData['total-iterations'] || 0;
+
+ disposeFitChart();
+
+ // Build scores display from progressData['best-scores']
+ // Include raw scores and length deductions for proper display
+ let scoresHtml = '';
+ const bestScores = progressData['best-scores'];
+ if (bestScores) {
+ // best-scores already has the right format: {'mae-max': ..., 'log-cosh': ..., 'r-squared': ...}
+ const bestSolution = {
+ scores: bestScores,
+ rawScores: progressData['best-raw-scores'],
+ lengthDeductions: progressData['length-deductions'],
+ score: progressData['best-score']
+ };
+ scoresHtml = formatAllScores(bestSolution, scoringMethod, scoreHistory);
+ } else if (progressData['best-score'] !== undefined) {
+ scoresHtml = `
+
+ Score (${getScoringMethodDisplay(scoringMethod)}):
+ ${formatScore(progressData['best-score'])}
+
+ `;
+ }
+
+ resultsDiv.innerHTML = `
+
+
+
+
Stopped
+
(iteration ${iteration}/${totalIterations})
+ ${datasetBadge}
+
+ ${labelDisplay}
+
+
+
Best Formula Found:
+
+
+
+
+
+
+ Complexity:
+ ${leafCount} nodes
+
+
+ Optimized for:
+ ${getScoringMethodDisplay(scoringMethod)}
+
+
+ Simplicity bias:
+ ${getSimplicityBiasDisplay(simplicityBias)}
+
+
+ ${scoresHtml}
+
+
+
+ `;
+
+ if (formula && formula !== 'N/A') {
+ renderFitChart(formula, true, inputXs, inputYs);
+ renderLatex('formula-latex', formula);
+ }
+}
+
+// ============================================================================
+// Job Controls (Pause/Stop)
+// ============================================================================
+
+function showJobControls() {
+ const jobControls = document.getElementById('job-controls');
+ if (jobControls) {
+ jobControls.classList.remove('hidden');
+ }
+}
+
+function hideJobControls() {
+ const jobControls = document.getElementById('job-controls');
+ if (jobControls) {
+ jobControls.classList.add('hidden');
+ }
+}
+
+function updatePauseButtonForJob(isPaused) {
+ const pauseBtn = document.getElementById('pause-btn');
+ const pauseBtnText = document.getElementById('pause-btn-text');
+
+ if (isPaused) {
+ pauseBtnText.textContent = 'Resume';
+ pauseBtn.classList.remove('bg-yellow-600', 'hover:bg-yellow-700');
+ pauseBtn.classList.add('bg-green-600', 'hover:bg-green-700');
+ } else {
+ pauseBtnText.textContent = 'Pause';
+ pauseBtn.classList.remove('bg-green-600', 'hover:bg-green-700');
+ pauseBtn.classList.add('bg-yellow-600', 'hover:bg-yellow-700');
+ }
+}
+
+// Toggle pause/resume for selected job
+async function togglePause() {
+ if (!selectedJobId) return;
+
+ const job = activeJobs.get(selectedJobId);
+ if (!job) return;
+
+ const endpoint = job.isPaused ? 'resume' : 'pause';
+
+ try {
+ const response = await fetch('/api/jobs/' + selectedJobId + '/' + endpoint, {
+ method: 'POST'
+ });
+ const data = await response.json();
+
+ if (data.error) {
+ console.error('Error toggling pause:', data.error);
+ return;
+ }
+
+ job.isPaused = !job.isPaused;
+ updatePauseButtonForJob(job.isPaused);
+ updateTabPauseIcon(selectedJobId, job.isPaused);
+ } catch (err) {
+ console.error('Error toggling pause:', err);
+ }
+}
+
+// Stop the selected job
+async function stopJob() {
+ if (!selectedJobId) return;
+
+ try {
+ const response = await fetch('/api/jobs/' + selectedJobId + '/stop', {
+ method: 'POST'
+ });
+ const data = await response.json();
+
+ if (data.error) {
+ console.error('Error stopping job:', data.error);
+ }
+ } catch (err) {
+ console.error('Error stopping job:', err);
+ }
+}
+
+// ============================================================================
+// CSV Upload
+// ============================================================================
+
+async function handleCsvUpload(input) {
+ const file = input.files[0];
+ if (!file) return;
+
+ const content = await file.text();
+
+ try {
+ const response = await fetch('/api/upload-csv', {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({content: content})
+ });
+
+ const data = await response.json();
+
+ if (data.error) {
+ alert('Error parsing CSV: ' + data.error);
+ return;
+ }
+
+ document.getElementById('xs').value = data.xs.join(', ');
+ document.getElementById('ys').value = data.ys.join(', ');
+
+ // Clear dataset name since data came from CSV
+ clearDatasetName();
+
+ initDataEditorChart();
+ input.value = '';
+ } catch (err) {
+ alert('Error uploading CSV: ' + err.message);
+ }
+}
+
+// ============================================================================
+// Job Submission
+// ============================================================================
+
+function submitSolverForm(evt) {
+ evt.preventDefault();
+
+ // Get dataset name BEFORE syncing (sync clears the name)
+ const datasetName = getSelectedDatasetName();
+
+ // Ensure any chart edits are synced to textareas before reading
+ // Pass true to skip clearing dataset name (we already captured it above)
+ if (typeof syncEditorToTextarea === 'function' && typeof editorData !== 'undefined' && editorData.length > 0) {
+ syncEditorToTextarea(true);
+ }
+
+ const xs = document.getElementById('xs').value.split(',').map(s => parseFloat(s.trim())).filter(n => !isNaN(n));
+ const ys = document.getElementById('ys').value.split(',').map(s => parseFloat(s.trim())).filter(n => !isNaN(n));
+
+ // Store for legacy compatibility
+ inputXs = xs;
+ inputYs = ys;
+
+ const config = {
+ iterations: parseInt(document.getElementById('iterations').value) || 20,
+ population: parseInt(document.getElementById('population').value) || 50,
+ maxLeafs: parseInt(document.getElementById('max-leafs').value) || 40
+ };
+
+ const seedValue = document.getElementById('seed').value;
+ if (seedValue) {
+ config.seed = parseInt(seedValue);
+ }
+
+ // Add adaptive mode, quiet logs, and eval cache settings
+ const adaptiveModeEl = document.getElementById('adaptive-mode');
+ const quietLogsEl = document.getElementById('quiet-logs');
+ const evalCacheEl = document.getElementById('eval-cache');
+ config.adaptiveMode = adaptiveModeEl && adaptiveModeEl.getAttribute('aria-checked') === 'true';
+ config.quietLogs = !(quietLogsEl && quietLogsEl.getAttribute('aria-checked') === 'true'); // Inverted: "Verbose Logging" toggle
+ config.useEvalCache = evalCacheEl && evalCacheEl.getAttribute('aria-checked') === 'true';
+
+ // Add scoring method
+ const scoringMethodEl = document.getElementById('scoring-method');
+ if (scoringMethodEl) {
+ config.scoringMethod = scoringMethodEl.value;
+ }
+
+ // Add simplicity bias
+ const simplicityBiasEl = document.getElementById('simplicity-bias');
+ if (simplicityBiasEl) {
+ config.simplicityBias = simplicityBiasEl.value;
+ }
+
+ // Add mutations blacklist if any mutations are excluded
+ const blacklist = getMutationsBlacklist();
+ if (blacklist.length > 0) {
+ config.mutationsBlacklist = blacklist;
+ }
+
+ console.debug('Submitting job: ', config);
+
+ startNewJob(xs, ys, config, datasetName);
+}
+
+// Start a new job (called from form submit or history buttons)
+function startNewJob(xs, ys, config, datasetName, sourceJobId = null) {
+ const endpoint = sourceJobId ? `/api/jobs/${sourceJobId}/continue` : '/api/solve';
+
+ fetch(endpoint, {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({xs: xs, ys: ys, config: config})
+ })
+ .then(response => response.json())
+ .then(data => {
+ if (data.jobId) {
+ // Create job entry with sequence number
+ jobCounter++;
+
+ // Find parent job's starting score if continuing from a previous job
+ let startingScore = null;
+ let startingScores = null;
+ let startingRawScores = null;
+ if (sourceJobId) {
+ const parentJob = jobHistory.find(j => j.id === sourceJobId);
+ if (parentJob) {
+ startingScore = parentJob.score;
+ startingScores = parentJob.scores;
+ // Store raw scores for fair comparison across different simplicity bias settings
+ startingRawScores = parentJob.rawScores || parentJob.scores;
+ }
+ }
+
+ const jobEntry = {
+ eventSource: null,
+ isPaused: false,
+ datasetName: datasetName,
+ sequenceNum: jobCounter,
+ startTime: Date.now(),
+ scoreHistory: [],
+ inputXs: xs,
+ inputYs: ys,
+ progress: null,
+ status: 'running',
+ config: config,
+ label: null, // Will be set below after we can call getJobDisplayName
+ sourceJobId: sourceJobId || null,
+ startingScore: startingScore,
+ startingScores: startingScores,
+ startingRawScores: startingRawScores
+ };
+
+ // Set default label using the display name
+ jobEntry.label = getJobDisplayName(jobEntry);
+
+ activeJobs.set(data.jobId, jobEntry);
+
+ // Create tab and setup SSE
+ createJobTab(data.jobId);
+ setupSSEConnection(data.jobId);
+ showJobControls();
+ } else if (data.error) {
+ document.getElementById('results').innerHTML = `
+
+
Error
+
${data.error}
+
+ `;
+ }
+ })
+ .catch(err => {
+ document.getElementById('results').innerHTML = `
+
+
Error
+
${err.message}
+
+ `;
+ });
+}
+
+// Set up SSE connection for a specific job
+function setupSSEConnection(jobId) {
+ const job = activeJobs.get(jobId);
+ if (!job) return;
+
+ const eventSource = new EventSource('/api/jobs/' + jobId + '/events');
+ job.eventSource = eventSource;
+
+ eventSource.addEventListener('progress', function(e) {
+ const data = JSON.parse(e.data);
+
+ // Update job state
+ job.progress = data;
+
+ // Store all scoring method values per iteration for sparklines
+ // Use raw scores (without length deduction) for consistent comparison
+ const rawScores = data['best-raw-scores'] || data['best-scores'] || {};
+ job.scoreHistory.push({
+ iteration: data.iteration,
+ score: data['best-score'],
+ 'mae-max': rawScores['mae-max'],
+ 'log-cosh': rawScores['log-cosh'],
+ 'r-squared': rawScores['r-squared']
+ });
+
+ // If this is the selected job, update UI
+ if (selectedJobId === jobId) {
+ renderProgressContent(jobId, job);
+ }
+ });
+
+ eventSource.addEventListener('complete', function(e) {
+ eventSource.close();
+ const data = JSON.parse(e.data);
+
+ // Capture elapsed time
+ const elapsedMs = job.startTime ? Date.now() - job.startTime : null;
+
+ // Save to history (use job.sourceJobId which we stored when starting)
+ saveToHistoryMultiJob(jobId, data, 'completed', [...job.scoreHistory], job.sourceJobId, elapsedMs, job.inputXs, job.inputYs, job.config, job.datasetName, job.label);
+
+ // Store completed job for display in Results
+ lastCompletedJob = {
+ status: 'completed',
+ data: data,
+ scoreHistory: [...job.scoreHistory],
+ inputXs: job.inputXs,
+ inputYs: job.inputYs,
+ datasetName: job.datasetName,
+ label: job.label
+ };
+
+ // Close tab (auto-close on complete)
+ closeJobTab(jobId);
+
+ // Re-enable start button if no more jobs
+ updateStartButtonState();
+ });
+
+ eventSource.addEventListener('error', function(e) {
+ eventSource.close();
+ let errorMessage = 'Unknown error';
+ if (e.data) {
+ const data = JSON.parse(e.data);
+ errorMessage = data.error;
+ }
+
+ // Show error if this is selected job
+ if (selectedJobId === jobId) {
+ document.getElementById('results').innerHTML = `
+
+
Error
+
${errorMessage}
+
+ `;
+ }
+
+ // Close tab
+ closeJobTab(jobId);
+ updateStartButtonState();
+ });
+
+ eventSource.addEventListener('stopped', function(e) {
+ eventSource.close();
+
+ // Capture elapsed time
+ const elapsedMs = job.startTime ? Date.now() - job.startTime : null;
+
+ // Parse data and save to history (use job.sourceJobId which we stored when starting)
+ if (e.data) {
+ const data = JSON.parse(e.data);
+ if (data['last-progress']) {
+ const progressData = data['last-progress'];
+ saveStoppedToHistoryMultiJob(jobId, progressData, [...job.scoreHistory], job.sourceJobId, elapsedMs, job.inputXs, job.inputYs, job.config, job.datasetName, job.label);
+
+ // Store stopped job for display in Results
+ lastCompletedJob = {
+ status: 'stopped',
+ progressData: progressData,
+ scoreHistory: [...job.scoreHistory],
+ inputXs: job.inputXs,
+ inputYs: job.inputYs,
+ config: job.config,
+ datasetName: job.datasetName,
+ label: job.label
+ };
+ }
+ }
+
+ // Close tab (auto-close on stop)
+ closeJobTab(jobId);
+ updateStartButtonState();
+ });
+
+ eventSource.onerror = function() {
+ // Connection error - might be normal end of stream
+ };
+}
+
+function updateStartButtonState() {
+ const hasRunningJobs = activeJobs.size > 0;
+
+ if (hasRunningJobs) {
+ showJobControls();
+ } else {
+ hideJobControls();
+ }
+}
+
+// ============================================================================
+// History Integration (multi-job versions)
+// ============================================================================
+
+function saveToHistoryMultiJob(jobId, jobData, status, scoreHistory, sourceJobId, elapsedMs, xs, ys, config, datasetName, label) {
+ const job = {
+ id: jobId,
+ parentId: sourceJobId || null,
+ timestamp: new Date().toLocaleString(),
+ status: status,
+ datasetName: datasetName,
+ label: label,
+ formula: jobData['best-solution'].formula,
+ score: jobData['best-solution'].score,
+ scores: jobData['best-solution'].scores || null, // All scoring method scores (with length deduction)
+ rawScores: jobData['best-solution'].rawScores || null, // Raw scores without length deduction
+ lengthDeductions: jobData['best-solution'].lengthDeductions || null, // Length deductions per method
+ scoringMethod: jobData['scoring-method'] || 'mae-max',
+ leafCount: jobData['best-solution'].leafCount,
+ xs: [...xs],
+ ys: [...ys],
+ config: { ...config },
+ allSolutions: jobData['all-solutions'],
+ scoreHistory: scoreHistory,
+ elapsedMs: elapsedMs
+ };
+ jobHistory.unshift(job);
+ renderJobHistory();
+}
+
+function saveStoppedToHistoryMultiJob(jobId, progressData, scoreHistory, parentId, elapsedMs, xs, ys, config, datasetName, label) {
+ if (!progressData) return;
+
+ const job = {
+ id: jobId,
+ parentId: parentId,
+ timestamp: new Date().toLocaleString(),
+ status: 'stopped',
+ datasetName: datasetName,
+ label: label,
+ formula: progressData['best-formula'],
+ score: progressData['best-score'],
+ scores: progressData['best-scores'] || null, // All scoring method scores (with length deduction)
+ rawScores: progressData['best-raw-scores'] || null, // Raw scores without length deduction
+ lengthDeductions: progressData['length-deductions'] || null, // Length deductions per method
+ scoringMethod: progressData['scoring-method'] || config.scoringMethod || 'mae-max',
+ leafCount: progressData['best-formula-leaf-count'] || null,
+ iteration: progressData['iteration'],
+ totalIterations: progressData['total-iterations'],
+ xs: [...xs],
+ ys: [...ys],
+ config: { ...config },
+ allSolutions: null,
+ scoreHistory: scoreHistory,
+ elapsedMs: elapsedMs
+ };
+ jobHistory.unshift(job);
+ renderJobHistory();
+}
+
+// Legacy functions kept for compatibility
+function resetUI() {
+ // This is now handled by closeJobTab
+ if (selectedJobId) {
+ closeJobTab(selectedJobId);
+ }
+}
+
+function showRunningState() {
+ // This is now handled by startNewJob and tab creation
+ disposeFitChart();
+}
+
+function updatePauseButton() {
+ if (selectedJobId) {
+ const job = activeJobs.get(selectedJobId);
+ if (job) {
+ updatePauseButtonForJob(job.isPaused);
+ }
+ }
+}
+
+// Stop all running jobs when page is closed or reloaded
+window.addEventListener('beforeunload', function() {
+ // Use sendBeacon to ensure requests are sent even during page unload
+ for (const jobId of activeJobs.keys()) {
+ navigator.sendBeacon('/api/jobs/' + jobId + '/stop', '');
+ }
+});
diff --git a/resources/public/js/solver-mutations.js b/resources/public/js/solver-mutations.js
new file mode 100644
index 00000000..e9b47885
--- /dev/null
+++ b/resources/public/js/solver-mutations.js
@@ -0,0 +1,315 @@
+/**
+ * Mutations selection and filtering with group support
+ */
+
+// All available mutations and selected state
+let allMutations = [];
+let selectedMutations = new Set();
+
+// Mutation groups - each mutation belongs to exactly one group
+const MUTATION_GROUPS = {
+ trig: {
+ name: 'Trig',
+ description: 'Sin, Cos, ArcSin, ArcCos operations',
+ mutations: [
+ '+Sin', '-Sin', '+Cos', '-Cos', '*Sin', '/Sin', '*Cos', '/Cos',
+ 'sin(x)', 'cos(x)', 'asin(x)', 'acos(x)',
+ 'Sin->Cos', 'Cos->Sin',
+ 'sin->cos', 'cos->sin', 'sin->asin', 'cos->acos',
+ 'b sin', 'b cos', 'b asin', 'b acos'
+ ]
+ },
+ explog: {
+ name: 'Exp/Log',
+ description: 'Exponential and logarithm operations',
+ mutations: [
+ '+Log', '-Log', '+Exp', '-Exp',
+ 'log(x)', 'exp(x)',
+ 'b exp', 'b log'
+ ]
+ },
+ poly: {
+ name: 'Polynomial',
+ description: 'x, x^2, sqrt operations',
+ mutations: [
+ '+x', '-x', '+x^2', '-x^2', '+x^1/2', '-x^1/2', '*x', '/x',
+ 'x^1/2', 'x^2',
+ 'b*b', 'b^1/2', 'b^-2', 'b^-1'
+ ]
+ },
+ analytic: {
+ name: 'Calculus',
+ description: 'Derivatives',
+ mutations: [
+ 'Derivative', 'b derivative'
+ ]
+ },
+ arithmetic: {
+ name: 'Arithmetic',
+ description: 'Constant scaling and offsets',
+ mutations: [
+ '+1/2', '-1/2', '+1/10', '-1/10', '+1/100', '-1/100',
+ '*2', '/2', '*10', '/10', '*100', '/100', '*1.1', '*0.9',
+ '1/f', '*-1',
+ 'x+1/2', 'x-1/2', 'x/10', '10*x', '1/x', 'x/100', '100*x', '-1*x', '1.1*x', '0.9*x',
+ 'x+1/10', 'x-1/10', 'x+1/100', 'x-1/100',
+ 'c/2', 'c*2', 'c*-1', 'c/10', 'c*10', 'c+1/10', 'c-1/10', '1/c', 'c+1/100', 'c-1/100', 'c+1/2', 'c-1/2',
+ 'b*-1', 'b*1.1', 'b*0.9', 'b+0.1', 'b-0.1'
+ ]
+ },
+ structure: {
+ name: 'Structure',
+ description: 'Operator swaps (+<->*, etc.)',
+ mutations: [
+ '+->*', '*->+', '^->*'
+ ]
+ }
+};
+
+// Track whether individual mutations view is expanded
+let showIndividualMutations = false;
+
+// Load mutations from API
+async function loadMutations() {
+ try {
+ const response = await fetch('/api/mutations');
+ const data = await response.json();
+ allMutations = data.mutations || [];
+
+ // Select all by default
+ selectedMutations = new Set(allMutations);
+
+ renderMutationsUI();
+ updateMutationsCount();
+ } catch (err) {
+ console.error('Error loading mutations:', err);
+ document.getElementById('mutations-count').textContent = '(error loading)';
+ }
+}
+
+// Get group for a mutation label (returns null if not in any defined group)
+function getMutationGroup(label) {
+ for (const [groupId, group] of Object.entries(MUTATION_GROUPS)) {
+ if (group.mutations.includes(label)) {
+ return groupId;
+ }
+ }
+ return null;
+}
+
+// Get all mutations in a group that actually exist in allMutations
+function getGroupMutations(groupId) {
+ const group = MUTATION_GROUPS[groupId];
+ if (!group) return [];
+ return group.mutations.filter(m => allMutations.includes(m));
+}
+
+// Check if all mutations in a group are selected
+function isGroupFullySelected(groupId) {
+ const groupMuts = getGroupMutations(groupId);
+ return groupMuts.length > 0 && groupMuts.every(m => selectedMutations.has(m));
+}
+
+// Check if some (but not all) mutations in a group are selected
+function isGroupPartiallySelected(groupId) {
+ const groupMuts = getGroupMutations(groupId);
+ const selectedCount = groupMuts.filter(m => selectedMutations.has(m)).length;
+ return selectedCount > 0 && selectedCount < groupMuts.length;
+}
+
+// Toggle all mutations in a group
+function toggleGroup(groupId, checked) {
+ const groupMuts = getGroupMutations(groupId);
+ groupMuts.forEach(m => {
+ if (checked) {
+ selectedMutations.add(m);
+ } else {
+ selectedMutations.delete(m);
+ }
+ });
+ updateGroupCheckbox(groupId);
+ updateIndividualCheckboxes();
+ updateMutationsCount();
+}
+
+// Update a group checkbox based on its mutations' state
+function updateGroupCheckbox(groupId) {
+ const checkbox = document.querySelector(`input[data-group="${groupId}"]`);
+ if (!checkbox) return;
+
+ const fullySelected = isGroupFullySelected(groupId);
+ const partiallySelected = isGroupPartiallySelected(groupId);
+
+ checkbox.checked = fullySelected;
+ checkbox.indeterminate = partiallySelected;
+}
+
+// Update all group checkboxes
+function updateAllGroupCheckboxes() {
+ Object.keys(MUTATION_GROUPS).forEach(updateGroupCheckbox);
+}
+
+// Update individual mutation checkboxes to match selectedMutations
+function updateIndividualCheckboxes() {
+ document.querySelectorAll('.mutation-checkbox').forEach(cb => {
+ cb.checked = selectedMutations.has(cb.value);
+ });
+}
+
+// Render the complete mutations UI (groups + individual)
+function renderMutationsUI() {
+ const container = document.getElementById('mutations-panel-content');
+ if (!container) return;
+
+ // Build groups HTML
+ let groupsHtml = '';
+ groupsHtml += '
Toggle by category:
';
+ groupsHtml += '
';
+
+ for (const [groupId, group] of Object.entries(MUTATION_GROUPS)) {
+ const groupMuts = getGroupMutations(groupId);
+ if (groupMuts.length === 0) continue; // Skip empty groups
+
+ const fullySelected = isGroupFullySelected(groupId);
+ const count = groupMuts.length;
+
+ groupsHtml += `
+
+ `;
+ }
+ groupsHtml += '
';
+
+ // Build individual mutations toggle
+ let individualHtml = `
+
+
+
+
+
+
+
+
+ ${renderMutationCheckboxes()}
+
+
+
+ `;
+
+ container.innerHTML = groupsHtml + individualHtml;
+ updateAllGroupCheckboxes();
+}
+
+// Render individual mutation checkboxes
+function renderMutationCheckboxes() {
+ return allMutations.map(label => `
+
+ `).join('');
+}
+
+// Toggle individual mutations panel visibility
+function toggleIndividualMutations() {
+ showIndividualMutations = !showIndividualMutations;
+ const panel = document.getElementById('individual-mutations-panel');
+ const chevron = document.getElementById('individual-chevron');
+
+ if (panel) {
+ panel.classList.toggle('hidden', !showIndividualMutations);
+ }
+ if (chevron) {
+ chevron.classList.toggle('rotate-180', showIndividualMutations);
+ }
+}
+
+// Escape HTML to prevent XSS
+function escapeHtml(text) {
+ const div = document.createElement('div');
+ div.textContent = text;
+ return div.innerHTML;
+}
+
+// Toggle a single mutation
+function toggleMutation(label, checked) {
+ if (checked) {
+ selectedMutations.add(label);
+ } else {
+ selectedMutations.delete(label);
+ }
+ updateAllGroupCheckboxes();
+ updateMutationsCount();
+}
+
+// Select all mutations
+function selectAllMutations() {
+ selectedMutations = new Set(allMutations);
+ updateIndividualCheckboxes();
+ updateAllGroupCheckboxes();
+ updateMutationsCount();
+}
+
+// Deselect all mutations
+function selectNoMutations() {
+ selectedMutations.clear();
+ updateIndividualCheckboxes();
+ updateAllGroupCheckboxes();
+ updateMutationsCount();
+}
+
+// Update the count display
+function updateMutationsCount() {
+ const countEl = document.getElementById('mutations-count');
+ if (countEl) {
+ const selected = selectedMutations.size;
+ const total = allMutations.length;
+ if (selected === total) {
+ countEl.textContent = `(all ${total} selected)`;
+ } else {
+ countEl.textContent = `(${selected}/${total} selected)`;
+ }
+ }
+}
+
+// Toggle the mutations panel visibility
+function toggleMutationsPanel() {
+ const panel = document.getElementById('mutations-panel');
+ const chevron = document.getElementById('mutations-chevron');
+
+ if (panel.classList.contains('hidden')) {
+ panel.classList.remove('hidden');
+ chevron.classList.add('rotate-180');
+ } else {
+ panel.classList.add('hidden');
+ chevron.classList.remove('rotate-180');
+ }
+}
+
+// Get the blacklist (mutations NOT selected)
+function getMutationsBlacklist() {
+ if (selectedMutations.size === allMutations.length) {
+ return []; // All selected, no blacklist needed
+ }
+ return allMutations.filter(m => !selectedMutations.has(m));
+}
+
+// Initialize on page load
+document.addEventListener('DOMContentLoaded', loadMutations);
diff --git a/resources/public/js/solver-presets.js b/resources/public/js/solver-presets.js
new file mode 100644
index 00000000..acbb8a39
--- /dev/null
+++ b/resources/public/js/solver-presets.js
@@ -0,0 +1,117 @@
+/**
+ * Dataset presets and generation
+ */
+
+// Track currently selected dataset name (null if data was manually edited)
+let selectedDatasetName = null;
+
+// Get the current dataset name (or null if edited)
+function getSelectedDatasetName() {
+ return selectedDatasetName;
+}
+
+// Clear dataset name when data is manually edited
+function clearDatasetName() {
+ selectedDatasetName = null;
+}
+
+// Formula generators for each dataset type
+const formulaGenerators = {
+ 'h-line': x => 0,
+ 'nguyen4': x => Math.pow(x, 6) + Math.pow(x, 5) + Math.pow(x, 4) + Math.pow(x, 3) + Math.pow(x, 2) + x,
+ 'nguyen5': x => Math.sin(x * x) * Math.cos(x) - 1,
+ 'feynman-lorentz': x => 1 / Math.sqrt(1 - x * x),
+ 'feynman-wave': x => Math.sin(x),
+ 'feynman-diffraction': x => {
+ const n = 5;
+ const sinHalf = Math.sin(x / 2);
+ const sinNHalf = Math.sin(n * x / 2);
+ if (Math.abs(sinHalf) < 1e-10) return n * n;
+ return (sinNHalf * sinNHalf) / (sinHalf * sinHalf);
+ },
+ 'feynman-planck': x => (x * x * x) / (Math.exp(x) - 1),
+ 'feynman-rutherford': x => {
+ const sinHalf = Math.sin(x / 2);
+ return 1 / Math.pow(sinHalf, 4);
+ },
+ 'feynman-ellipse': x => {
+ const e = 0.6;
+ const a = 1.0;
+ return (a * (1 - e * e)) / (1 + e * Math.cos(x));
+ },
+ 'feynman-transition': x => {
+ // Quantum transition probability (sinc² function) from Feynman III.9.52
+ if (Math.abs(x) < 1e-10) return 1.0; // limit as x->0 is 1
+ return (Math.sin(x) * Math.sin(x)) / (x * x);
+ }
+};
+
+// Update the number display when slider changes
+function updatePointsValue(val) {
+ document.getElementById('num-points').value = val;
+}
+
+// Regenerate data for the current preset with the new number of points
+function regeneratePreset() {
+ const select = document.getElementById('preset-select');
+ const presetId = select.value;
+ if (!presetId) return;
+
+ // Find option by value (more reliable than selectedIndex)
+ const option = select.querySelector(`option[value="${presetId}"]`) || select.options[select.selectedIndex];
+ if (!option) return;
+ const formula = option.dataset.formula;
+
+ if (formula && formulaGenerators[formula]) {
+ const numPoints = parseInt(document.getElementById('num-points').value) || 20;
+ const min = parseFloat(option.dataset.min);
+ const max = parseFloat(option.dataset.max);
+
+ const xs = linspace(min, max, numPoints);
+ const ys = xs.map(formulaGenerators[formula]);
+
+ document.getElementById('xs').value = xs.map(x => x.toFixed(4)).join(', ');
+ document.getElementById('ys').value = ys.map(y => y.toFixed(6)).join(', ');
+
+ initDataEditorChart();
+ }
+}
+
+// Load preset dataset
+function loadPreset(presetId) {
+ if (!presetId) {
+ document.getElementById('points-container').classList.add('hidden');
+ selectedDatasetName = null;
+ return;
+ }
+
+ const select = document.getElementById('preset-select');
+ // Find option by value (more reliable than selectedIndex when called programmatically)
+ const option = select.querySelector(`option[value="${presetId}"]`) || select.options[select.selectedIndex];
+ if (!option) {
+ selectedDatasetName = null;
+ return;
+ }
+ const formula = option.dataset.formula;
+
+ // Store the dataset name
+ selectedDatasetName = option.textContent.trim();
+
+ const pointsContainer = document.getElementById('points-container');
+ if (formula && formulaGenerators[formula]) {
+ pointsContainer.classList.remove('hidden');
+ regeneratePreset();
+ } else {
+ pointsContainer.classList.add('hidden');
+ let xs = option.dataset.xs;
+ let ys = option.dataset.ys;
+
+ xs = parseArrayString(xs);
+ ys = parseArrayString(ys);
+
+ document.getElementById('xs').value = xs;
+ document.getElementById('ys').value = ys;
+
+ initDataEditorChart();
+ }
+}
diff --git a/resources/public/js/solver-utils.js b/resources/public/js/solver-utils.js
new file mode 100644
index 00000000..2e3b298d
--- /dev/null
+++ b/resources/public/js/solver-utils.js
@@ -0,0 +1,59 @@
+/**
+ * Utility functions for the solver
+ */
+
+// Debounce helper
+function debounce(func, wait) {
+ let timeout;
+ return function(...args) {
+ clearTimeout(timeout);
+ timeout = setTimeout(() => func.apply(this, args), wait);
+ };
+}
+
+// Copy formula to clipboard
+function copyFormula(elementId) {
+ const el = document.getElementById(elementId);
+ if (!el) return;
+ const text = el.textContent;
+ navigator.clipboard.writeText(text).then(() => {
+ // Brief visual feedback
+ el.classList.add('text-white');
+ setTimeout(() => el.classList.remove('text-white'), 200);
+ });
+}
+
+// Generate evenly spaced points
+function linspace(min, max, n) {
+ const step = (max - min) / (n - 1);
+ return Array.from({length: n}, (_, i) => min + i * step);
+}
+
+// Handle Clojure vector format [1 2 3] or JSON format [1,2,3]
+function parseArrayString(str) {
+ if (str.startsWith('[') && str.endsWith(']')) {
+ const inner = str.slice(1, -1).trim();
+ return inner.split(/[,\s]+/).filter(s => s.length > 0).join(', ');
+ }
+ return str;
+}
+
+// Toggle switch component handler
+function toggleSwitch(button) {
+ const isChecked = button.getAttribute('aria-checked') === 'true';
+ const newState = !isChecked;
+ button.setAttribute('aria-checked', newState.toString());
+
+ // Update visual state
+ if (newState) {
+ button.classList.remove('bg-gray-600');
+ button.classList.add('bg-blue-600');
+ button.querySelector('.toggle-knob').classList.remove('translate-x-0');
+ button.querySelector('.toggle-knob').classList.add('translate-x-5');
+ } else {
+ button.classList.remove('bg-blue-600');
+ button.classList.add('bg-gray-600');
+ button.querySelector('.toggle-knob').classList.remove('translate-x-5');
+ button.querySelector('.toggle-knob').classList.add('translate-x-0');
+ }
+}
diff --git a/resources/public/site.webmanifest b/resources/public/site.webmanifest
new file mode 100644
index 00000000..45dc8a20
--- /dev/null
+++ b/resources/public/site.webmanifest
@@ -0,0 +1 @@
+{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
\ No newline at end of file
diff --git a/resources/templates/base.html b/resources/templates/base.html
new file mode 100644
index 00000000..8a59ea14
--- /dev/null
+++ b/resources/templates/base.html
@@ -0,0 +1,97 @@
+
+
+
+
+
+ {{title|default:"Closyr"}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% block head %}{% endblock %}
+
+
+
+
+
+
+
+ {% block content %}{% endblock %}
+
+
+
+
+
+ {% block scripts %}{% endblock %}
+
+
diff --git a/resources/templates/index.html b/resources/templates/index.html
new file mode 100644
index 00000000..4531ea26
--- /dev/null
+++ b/resources/templates/index.html
@@ -0,0 +1,78 @@
+{% extends "base.html" %}
+
+{% block content %}
+
+
+ Welcome to Closyr
+
+
+ Find mathematical formulas that fit your data using genetic algorithms
+ and symbolic regression.
+
+
+
+ Start Solving
+
+
+
+
+
+
+
+
+
f(x)
+
Symbolic Regression
+
+ Discover the mathematical formula that best describes your data,
+ not just a fitted curve.
+
+
+
+
+
GA
+
Genetic Algorithms
+
+ Uses evolutionary computing to explore the space of possible
+ mathematical expressions.
+
+
+
+
+
RT
+
Real-time Progress
+
+ Watch the solver evolve formulas in real-time with live updates
+ via Server-Sent Events.
+
+
+
+
+
+
+
+
How It Works
+
+
+ -
+ Provide your data -
+ Enter X and Y values, upload a CSV file, or use a preset dataset.
+
+ -
+ Configure the solver -
+ Set the number of iterations, population size, and other parameters.
+
+ -
+ Watch it evolve -
+ The genetic algorithm will breed and mutate mathematical expressions.
+
+ -
+ Get your formula -
+ Receive the best-fit symbolic expression for your data.
+
+
+
+
+{% endblock %}
diff --git a/resources/templates/solver.html b/resources/templates/solver.html
new file mode 100644
index 00000000..71434696
--- /dev/null
+++ b/resources/templates/solver.html
@@ -0,0 +1,320 @@
+{% extends "base.html" %}
+
+{% block content %}
+
+
Symbolic Regression Solver
+
+
+
+
+
+
+
+
+
Results
+
+
+
+
+
+
+
+
+
+
+
+
+
Enter your data and click "Find Formula" to start the solver.
+
+ The genetic algorithm will evolve mathematical expressions to find
+ the best fit for your data.
+
+
+
+
+
+
+
+
+{% endblock %}
+
+{% block scripts %}
+
+
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/run-junit.sh b/run-junit.sh
new file mode 100755
index 00000000..800ab90e
--- /dev/null
+++ b/run-junit.sh
@@ -0,0 +1,25 @@
+#!/bin/bash
+# Run JUnit tests for Java API
+
+set -e
+
+# Compile main Java sources
+lein with-profile +test javac
+
+# Get classpath
+CP=$(lein with-profile +test classpath 2>/dev/null)
+
+# Create test-classes directory
+mkdir -p target/test-classes
+
+# Compile Java test sources manually
+javac -d target/test-classes \
+ -cp "target/classes:$CP" \
+ test/org/closyr/core/FindFormulaTest.java
+
+# Run JUnit
+java -jar ~/.m2/repository/org/junit/platform/junit-platform-console-standalone/6.0.1/junit-platform-console-standalone-6.0.1.jar \
+ execute \
+ --class-path "target/test-classes:target/classes:$CP" \
+ --select-class org.closyr.core.FindFormulaTest \
+ --details tree
diff --git a/screenshots/test_coverage_2025-12-29_17-44.png b/screenshots/test_coverage_2025-12-29_17-44.png
new file mode 100644
index 00000000..aa64c41b
Binary files /dev/null and b/screenshots/test_coverage_2025-12-29_17-44.png differ
diff --git a/src/closyr/adaptive.clj b/src/closyr/adaptive.clj
new file mode 100644
index 00000000..e54345c4
--- /dev/null
+++ b/src/closyr/adaptive.clj
@@ -0,0 +1,227 @@
+(ns closyr.adaptive
+ "Adaptive mutation rate control based on population metrics.
+
+ Tracks population diversity, stagnation, and mutation effectiveness
+ to dynamically adjust:
+ - Mutation vs crossover probability
+ - Number of mutations per individual
+ - (Future) Mutation type weights"
+ (:require
+ [closyr.util.log :as log]))
+
+
+(set! *warn-on-reflection* true)
+
+
+;; =============================================================================
+;; Adaptive State
+;; =============================================================================
+
+(def ^:private default-state
+ "Default adaptive state - starts with balanced exploration/exploitation"
+ {:mutation-probability 0.8 ; probability of mutation vs crossover
+ :mutation-count-boost 1.0 ; multiplier for mutation count (1.0 = normal)
+ :best-score-history [] ; recent best scores for stagnation detection
+ :diversity-history [] ; recent diversity values
+ :stagnation-counter 0 ; iterations without improvement
+ :last-best-score nil}) ; previous best score for comparison
+
+
+(def ^:private adaptive-state*
+ "Atom holding current adaptive state"
+ (atom default-state))
+
+
+(defn reset-adaptive-state!
+ "Reset adaptive state to defaults. Call at start of new run."
+ []
+ (reset! adaptive-state* default-state))
+
+
+(defn get-adaptive-state
+ "Get current adaptive state"
+ []
+ @adaptive-state*)
+
+
+;; =============================================================================
+;; Configuration
+;; =============================================================================
+
+(def ^:private config
+ {:history-window 40 ; number of iterations to track for trends
+ :stagnation-threshold 20 ; iterations without improvement before boosting
+ :min-mutation-prob 0.5 ; minimum mutation probability
+ :max-mutation-prob 0.95 ; maximum mutation probability
+ :min-mutation-boost 0.5 ; minimum mutation count multiplier
+ :max-mutation-boost 2.0 ; maximum mutation count multiplier
+ :diversity-low-thresh 0.1 ; diversity below this triggers exploration
+ :diversity-high-thresh 0.5 ; diversity above this triggers exploitation
+ :improvement-threshold 0.001}) ; minimum improvement to count as progress
+
+
+;; =============================================================================
+;; Diversity Calculation
+;; =============================================================================
+
+(defn calculate-diversity
+ "Calculate population diversity as normalized score spread.
+ Returns value between 0 (no diversity) and 1 (high diversity).
+
+ Uses the gap between best and median scores, normalized by score magnitude."
+ [sorted-scores]
+ (when (seq sorted-scores)
+ (let [n (count sorted-scores)
+ best-score (double (first sorted-scores))
+ median-idx (quot n 2)
+ median-score (double (nth sorted-scores median-idx))
+ p90-idx (min (dec n) (int (* 0.1 n)))
+ p90-score (double (nth sorted-scores p90-idx))
+ ;; Normalize by score magnitude to get relative diversity
+ score-range (Math/abs (- p90-score best-score))
+ normalizer (max 1.0 (Math/abs best-score))]
+ (min 1.0 (/ score-range normalizer)))))
+
+
+;; =============================================================================
+;; Stagnation Detection
+;; =============================================================================
+
+(defn- detect-stagnation
+ "Check if we're making progress or stagnating.
+ Returns updated stagnation counter."
+ [current-best last-best threshold]
+ (if (nil? last-best)
+ 0
+ (let [improvement (- current-best last-best)]
+ (if (> improvement threshold)
+ 0 ; reset counter on improvement
+ 1)))) ; increment will happen in update
+
+
+(defn- update-stagnation-counter
+ "Update stagnation counter based on improvement"
+ [{:keys [stagnation-counter last-best-score]} current-best]
+ (let [threshold (:improvement-threshold config)]
+ (if (nil? last-best-score)
+ 0
+ (if (> (- current-best last-best-score) threshold)
+ 0
+ (inc stagnation-counter)))))
+
+
+;; =============================================================================
+;; Adaptive Rate Calculation
+;; =============================================================================
+
+(defn- calculate-mutation-probability
+ "Calculate mutation probability based on diversity and stagnation.
+
+ Low diversity OR stagnation → increase mutation (exploration)
+ High diversity AND progress → decrease mutation (exploitation)"
+ [diversity stagnation-counter]
+ (let [{:keys [min-mutation-prob max-mutation-prob
+ diversity-low-thresh diversity-high-thresh
+ stagnation-threshold]} config
+ ;; Stagnation boost: increase mutation when stuck
+ stagnation-boost (if (>= stagnation-counter stagnation-threshold)
+ 0.15
+ 0.0)
+ ;; Diversity-based adjustment
+ diversity-adj (cond
+ (< diversity diversity-low-thresh)
+ 0.1 ; low diversity → more mutation
+
+ (> diversity diversity-high-thresh)
+ -0.1 ; high diversity → less mutation
+
+ :else 0.0)
+ ;; Base probability with adjustments
+ base-prob 0.8
+ new-prob (+ base-prob diversity-adj stagnation-boost)]
+ (max min-mutation-prob (min max-mutation-prob new-prob))))
+
+
+(defn- calculate-mutation-count-boost
+ "Calculate mutation count multiplier based on stagnation.
+
+ When stuck, apply more mutations per individual to explore more aggressively."
+ [stagnation-counter diversity]
+ (let [{:keys [min-mutation-boost max-mutation-boost
+ stagnation-threshold diversity-low-thresh]} config
+ ;; Strong boost when stagnating
+ stagnation-boost (if (>= stagnation-counter stagnation-threshold)
+ (min 0.5 (* 0.1 (- stagnation-counter stagnation-threshold)))
+ 0.0)
+ ;; Slight boost for low diversity
+ diversity-boost (if (< diversity diversity-low-thresh)
+ 0.2
+ 0.0)
+ new-boost (+ 1.0 stagnation-boost diversity-boost)]
+ (max min-mutation-boost (min max-mutation-boost new-boost))))
+
+
+;; =============================================================================
+;; State Update
+;; =============================================================================
+
+(defn update-adaptive-state!
+ "Update adaptive state based on current population metrics.
+
+ Call this once per iteration with the sorted population scores.
+ Returns the updated state."
+ [sorted-scores]
+ (let [current-best (when (seq sorted-scores) (double (first sorted-scores)))
+ diversity (or (calculate-diversity sorted-scores) 0.5)]
+ (swap! adaptive-state*
+ (fn [{:keys [best-score-history diversity-history] :as state}]
+ (let [new-stagnation (update-stagnation-counter state current-best)
+ new-mutation-prob (calculate-mutation-probability diversity new-stagnation)
+ new-mutation-boost (calculate-mutation-count-boost new-stagnation diversity)
+ ;; Keep bounded history
+ window (:history-window config)
+ new-best-history (take window (cons current-best best-score-history))
+ new-div-history (take window (cons diversity diversity-history))]
+ (-> state
+ (assoc :mutation-probability new-mutation-prob)
+ (assoc :mutation-count-boost new-mutation-boost)
+ (assoc :stagnation-counter new-stagnation)
+ (assoc :last-best-score current-best)
+ (assoc :best-score-history (vec new-best-history))
+ (assoc :diversity-history (vec new-div-history))))))))
+
+
+;; =============================================================================
+;; Public API for GA/Ops
+;; =============================================================================
+
+(defn should-mutate?
+ "Returns true if mutation should be used, false for crossover.
+ Uses current adaptive mutation probability."
+ []
+ (< (rand) (:mutation-probability @adaptive-state*)))
+
+
+(defn get-mutation-count-boost
+ "Returns current mutation count multiplier (1.0 = normal)."
+ []
+ (:mutation-count-boost @adaptive-state*))
+
+
+(defn get-mutation-probability
+ "Returns current mutation probability (0.0-1.0)."
+ []
+ (:mutation-probability @adaptive-state*))
+
+
+(defn format-adaptive-status
+ "Format current adaptive state for logging."
+ []
+ (let [{:keys [mutation-probability mutation-count-boost
+ stagnation-counter diversity-history]} @adaptive-state*
+ recent-diversity (first diversity-history)]
+ (format "Adaptive: mut=%.0f%% boost=%.1fx stag=%d div=%.2f"
+ (* 100 mutation-probability)
+ mutation-count-boost
+ stagnation-counter
+ (or recent-diversity 0.0))))
diff --git a/src/closyr/api/finder.clj b/src/closyr/api/finder.clj
new file mode 100644
index 00000000..74eacfbc
--- /dev/null
+++ b/src/closyr/api/finder.clj
@@ -0,0 +1,146 @@
+(ns closyr.api.finder
+ "Main entry point for the Java API.
+ This namespace uses gen-class to create a Java-friendly FormulaFinder class."
+ (:require
+ [closyr.api.types :as types]
+ [closyr.ga :as ga]
+ [closyr.ops.common :as ops-common]
+ [closyr.ops.initialize :as ops-init]
+ [closyr.symbolic-regression :as symreg]
+ [closyr.util.log :as log]
+ [closyr.util.prng :as prng])
+ (:import
+ (org.closyr.api
+ IFormulaConfig
+ IFormulaFinder
+ IFormulaResult))
+ (:gen-class
+ :name org.closyr.api.FormulaFinder
+ :implements [org.closyr.api.IFormulaFinder]
+ :methods [^:static [create [] org.closyr.api.IFormulaFinder]
+ ^:static [createConfig [] org.closyr.api.IFormulaConfig]
+ ^:static [createConfig [int int int] org.closyr.api.IFormulaConfig]
+ ^:static [find ["[D" "[D"] org.closyr.api.IFormulaResult]
+ ^:static [find ["[D" "[D" org.closyr.api.IFormulaConfig] org.closyr.api.IFormulaResult]]))
+
+
+(set! *warn-on-reflection* true)
+
+
+(defn- array->vec
+ "Convert Java array to Clojure vector, handling nil"
+ [^"[Ljava.lang.String;" arr]
+ (when arr
+ (vec arr)))
+
+
+(defn run-solver
+ "Run the symbolic regression solver with the given parameters."
+ [xs ys config]
+ (let [^doubles xs-arr xs
+ ^doubles ys-arr ys
+ ^IFormulaConfig cfg config
+ xs-vec (vec xs-arr)
+ ys-vec (vec ys-arr)
+ iterations (.getIterations cfg)
+ population-size (.getPopulationSize cfg)
+ max-leafs (.getMaxLeafs cfg)
+ random-seed (.getRandomSeed cfg)
+ adaptive-mode (.isAdaptiveMode cfg)
+ quiet-logs (.isQuietLogs cfg)
+ use-eval-cache (.isUseEvalCache cfg)
+ scoring-method-str (.getScoringMethod cfg)
+ scoring-method (when scoring-method-str (keyword scoring-method-str))
+ whitelist (array->vec (.getMutationsWhitelist cfg))
+ blacklist (array->vec (.getMutationsBlacklist cfg))
+ initial-muts (if (or whitelist blacklist)
+ (ops-init/filter-mutations {:whitelist whitelist
+ :blacklist blacklist})
+ (ops-init/initial-mutations))
+ run-config {:initial-phenos (ops-init/initial-phenotypes population-size)
+ :initial-muts initial-muts
+ :iters iterations
+ :use-gui? false
+ :use-flamechart false
+ :max-leafs max-leafs
+ :random-seed random-seed
+ :adaptive-mode adaptive-mode
+ :quiet-logs quiet-logs
+ :use-eval-cache use-eval-cache
+ :scoring-method scoring-method
+ :input-xs-exprs (ops-common/doubles->exprs xs-vec)
+ :input-ys-exprs (ops-common/doubles->exprs ys-vec)}
+
+ _ (log/info "API: using" (count initial-muts) "mutations"
+ "adaptive:" adaptive-mode "quiet:" quiet-logs "scoring:" scoring-method)
+ result (symreg/run-find-formula run-config)]
+
+ (types/->formula-result result)))
+
+
+(defn validate-inputs
+ "Validate xs and ys arrays."
+ [xs ys]
+ (when (nil? xs)
+ (throw (IllegalArgumentException. "xs array cannot be null")))
+ (when (nil? ys)
+ (throw (IllegalArgumentException. "ys array cannot be null")))
+ (let [^doubles xs-arr xs
+ ^doubles ys-arr ys]
+ (when (not= (alength xs-arr) (alength ys-arr))
+ (throw (IllegalArgumentException.
+ (str "xs and ys arrays must have the same length, got "
+ (alength xs-arr) " and " (alength ys-arr)))))
+ (when (< (alength xs-arr) 2)
+ (throw (IllegalArgumentException.
+ (str "At least 2 data points are required, got " (alength xs-arr)))))))
+
+
+;; ============================================================================
+;; Instance methods (IFormulaFinder implementation)
+;; ============================================================================
+
+(defn -findFormula
+ "Find a formula that fits the given data points."
+ ([this xs ys]
+ (-findFormula this xs ys (types/config)))
+ ([_ xs ys config]
+ (let [^doubles xs-arr xs
+ ^doubles ys-arr ys
+ ^IFormulaConfig cfg config]
+ (validate-inputs xs-arr ys-arr)
+ (run-solver xs-arr ys-arr cfg))))
+
+
+;; ============================================================================
+;; Static factory methods
+;; ============================================================================
+
+(defn -create
+ "Create a new FormulaFinder instance."
+ []
+ ;; The instance is created by gen-class, we just return it
+ ;; This is called via FormulaFinder.create()
+ (eval '(org.closyr.api.FormulaFinder.)))
+
+
+(defn -createConfig
+ "Create a configuration with default or custom values."
+ ([]
+ (types/config))
+ ([iterations population-size max-leafs]
+ (types/config {:iterations (int iterations)
+ :population-size (int population-size)
+ :max-leafs (int max-leafs)})))
+
+
+(defn -find
+ "Static method to find a formula without creating an instance."
+ ([xs ys]
+ (-find xs ys (types/config)))
+ ([xs ys config]
+ (let [^doubles xs-arr xs
+ ^doubles ys-arr ys
+ ^IFormulaConfig cfg config]
+ (validate-inputs xs-arr ys-arr)
+ (run-solver xs-arr ys-arr cfg))))
diff --git a/src/closyr/api/types.clj b/src/closyr/api/types.clj
new file mode 100644
index 00000000..aee252a6
--- /dev/null
+++ b/src/closyr/api/types.clj
@@ -0,0 +1,181 @@
+(ns closyr.api.types
+ "Java-friendly types for the symbolic regression API.
+ These types implement Java interfaces for seamless Java interop."
+ (:import
+ (org.closyr.api
+ IFormulaConfig
+ IFormulaResult
+ IFormulaSolution)
+ (org.matheclipse.core.interfaces
+ IExpr)))
+
+
+(set! *warn-on-reflection* true)
+
+
+;; ============================================================================
+;; FormulaSolution - implements IFormulaSolution
+;; ============================================================================
+
+(deftype FormulaSolution [^String formula
+ ^double score
+ ^IExpr expr
+ ^int leaf-count]
+
+ IFormulaSolution
+
+ (getFormula [_] formula)
+
+ (getScore [_] score)
+
+ (getExpr [_] expr)
+
+ (getLeafCount [_] leaf-count)
+
+ Object
+
+ (toString [_]
+ (str "FormulaSolution{formula='" formula "', score=" score
+ ", leafCount=" leaf-count "}")))
+
+
+(defn ->formula-solution
+ "Create a FormulaSolution from a phenotype map."
+ [{:keys [expr score]}]
+ (let [^IExpr e expr
+ formula-str (if e (str e) "unknown")
+ leaf-count (if e (.leafCount e) 0)
+ score-val (if (number? score) (double score) Double/NEGATIVE_INFINITY)]
+ (FormulaSolution. formula-str score-val e leaf-count)))
+
+
+;; ============================================================================
+;; FormulaResult - implements IFormulaResult
+;; ============================================================================
+
+(deftype FormulaResult [^IFormulaSolution best-solution
+ ^java.util.List all-solutions
+ ^int iterations-done]
+
+ IFormulaResult
+
+ (getBestSolution [_] best-solution)
+
+ (getAllSolutions [_] all-solutions)
+
+ (getIterationsDone [_] iterations-done)
+
+ Object
+
+ (toString [_]
+ (str "FormulaResult{bestFormula='"
+ (when best-solution (.getFormula best-solution))
+ "', bestScore="
+ (when best-solution (.getScore best-solution))
+ ", iterations=" iterations-done
+ ", solutionCount=" (count all-solutions) "}")))
+
+
+(defn ->formula-result
+ "Create a FormulaResult from solver output."
+ [{:keys [iters-done final-population]}]
+ (let [phenotypes (get final-population :pop [])
+ ;; Convert all phenotypes to solutions
+ solutions (->> phenotypes
+ (map ->formula-solution)
+ ;; Sort by score descending (higher/closer to 0 is better)
+ (sort-by #(.getScore ^IFormulaSolution %) #(compare %2 %1))
+ vec)
+ best (first solutions)
+ solutions-list (java.util.ArrayList. ^java.util.Collection solutions)]
+ (FormulaResult. best solutions-list (int (or iters-done 0)))))
+
+
+;; ============================================================================
+;; FormulaConfig - implements IFormulaConfig
+;; ============================================================================
+
+(deftype FormulaConfig [^int iterations
+ ^int population-size
+ ^int max-leafs
+ ^long random-seed
+ ^boolean adaptive-mode
+ ^boolean quiet-logs
+ ^boolean use-eval-cache
+ ^String scoring-method]
+
+ IFormulaConfig
+
+ (getIterations [_] iterations)
+
+ (getPopulationSize [_] population-size)
+
+ (getMaxLeafs [_] max-leafs)
+
+ (getRandomSeed [_] random-seed)
+
+ (isAdaptiveMode [_] adaptive-mode)
+
+ (isQuietLogs [_] quiet-logs)
+
+ (isUseEvalCache [_] use-eval-cache)
+
+ (getScoringMethod [_] scoring-method)
+
+ Object
+
+ (toString [_]
+ (str "FormulaConfig{iterations=" iterations
+ ", populationSize=" population-size
+ ", maxLeafs=" max-leafs
+ ", randomSeed=" random-seed
+ ", adaptiveMode=" adaptive-mode
+ ", quietLogs=" quiet-logs
+ ", useEvalCache=" use-eval-cache
+ ", scoringMethod=" scoring-method "}")))
+
+
+(defn config
+ "Create a FormulaConfig with the given options.
+
+ Options:
+ :iterations - Number of GA iterations (default: 20)
+ :population-size - Population size (default: 100)
+ :max-leafs - Max expression tree leaves (default: 40)
+ :random-seed - Random seed for reproducibility (default: -1, meaning no seed)
+ :adaptive-mode - Enable adaptive mutation rates (default: false)
+ :quiet-logs - Suppress detailed iteration logs (default: false)
+ :use-eval-cache - Enable evaluation caching (default: false)
+ :scoring-method - Scoring method: \"mae-max\", \"log-cosh\", or \"r-squared\" (default: \"mae-max\")"
+ ([]
+ (config {}))
+ ([{:keys [iterations population-size max-leafs random-seed adaptive-mode quiet-logs use-eval-cache scoring-method]
+ :or {iterations 20
+ population-size 100
+ max-leafs 40
+ random-seed -1
+ adaptive-mode false
+ quiet-logs false
+ use-eval-cache false
+ scoring-method "mae-max"}}]
+ (FormulaConfig. (int iterations)
+ (int population-size)
+ (int max-leafs)
+ (long random-seed)
+ (boolean adaptive-mode)
+ (boolean quiet-logs)
+ (boolean use-eval-cache)
+ (str scoring-method))))
+
+
+(defn config->map
+ "Convert an IFormulaConfig to a Clojure map."
+ [^IFormulaConfig cfg]
+ {:iterations (.getIterations cfg)
+ :population-size (.getPopulationSize cfg)
+ :max-leafs (.getMaxLeafs cfg)
+ :random-seed (.getRandomSeed cfg)
+ :adaptive-mode (.isAdaptiveMode cfg)
+ :quiet-logs (.isQuietLogs cfg)
+ :use-eval-cache (.isUseEvalCache cfg)
+ :scoring-method (.getScoringMethod cfg)})
diff --git a/src/closyr/core.clj b/src/closyr/core.clj
index 01833066..3f55ec6e 100644
--- a/src/closyr/core.clj
+++ b/src/closyr/core.clj
@@ -5,7 +5,9 @@
[clojure.tools.cli :as cli]
[closyr.symbolic-regression :as symreg]
[closyr.util.csv :as input-csv]
- [closyr.util.log :as log])
+ [closyr.util.log :as log]
+ [closyr.util.prng :as prng]
+ [closyr.web.server :as web-server])
(:import
(java.io
File)))
@@ -33,6 +35,15 @@
(log/error "Can't parse numbers str: " numbers-str " : " (.getMessage e)))))
+(defn- str->string-vec
+ "Parse comma-separated strings into a vector"
+ [s]
+ (when (and s (not (str/blank? s)))
+ (->> (str/split s #"\,")
+ (mapv str/trim)
+ (filterv (complement str/blank?)))))
+
+
(def ^:private cli-options
[["-i" "--iterations ITERATIONS" "Number of iterations"
:default 10
@@ -77,6 +88,44 @@
:default false
:id :use-flamechart]
+ ["-s" "--seed SEED" "Random seed for reproducible results"
+ :default nil
+ :parse-fn #(Long/parseLong %)
+ :id :seed]
+
+ ["-w" "--mutations-whitelist WHITELIST" "Comma-separated list of mutation labels to use (whitelist)"
+ :default nil
+ :parse-fn str->string-vec
+ :id :mutations-whitelist]
+
+ ["-b" "--mutations-blacklist BLACKLIST" "Comma-separated list of mutation labels to exclude (blacklist)"
+ :default nil
+ :parse-fn str->string-vec
+ :id :mutations-blacklist]
+
+ ["-a" "--adaptive" "Enable adaptive mutation rates (adjusts based on population diversity)"
+ :default false
+ :id :adaptive-mode]
+
+ ["-q" "--quiet" "Quiet logging mode (suppress detailed iteration logs)"
+ :default false
+ :id :quiet-logs]
+
+ [nil "--cache" "Enable evaluation cache (cache scores by expression string)"
+ :default false
+ :id :use-eval-cache]
+
+ [nil "--scoring METHOD" "Scoring method: mae-max (default), log-cosh, or r-squared"
+ :default :mae-max
+ :parse-fn keyword
+ :validate [#{:mae-max :log-cosh :r-squared} "Scoring method must be: mae-max, log-cosh, or r-squared"]
+ :id :scoring-method]
+
+ [nil "--web [PORT]" "Start HTTP web server instead of GUI (default port: 3000)"
+ :default nil
+ :parse-fn #(if (str/blank? %) 3000 (Integer/parseInt %))
+ :id :web-port]
+
#_["-v" nil "Verbosity level"
:id :verbosity
:default 0
@@ -110,7 +159,7 @@
(and xs (nil? ys)) (log/error "Error: only XS provided, please provide YS. XS/YS: " xs ys)
(and ys (nil? xs)) (log/error "Error: only YS provided, please provide XS. XS/YS: " xs ys)
:else opts)]
- (dissoc opts :infile)))
+ (dissoc opts :infile :web-port)))
(def ^:private big-text
@@ -132,7 +181,16 @@ ________/\\\\\\\\\__/\\\___________________/\\\\\__________/\\\\\\\\\\\____/\\\_
[& args]
(log/info big-text)
- (some->
- (parse-main-opts args)
- (validate-symreg-opts)
- (symreg/run-app-from-cli-args)))
+ (let [opts (parse-main-opts args)]
+ (if-let [port (:web-port opts)]
+ ;; Web server mode
+ (do
+ (log/info "CLI starting web server on port" port)
+ (web-server/start! {:port port})
+ ;; Keep the main thread alive
+ @(promise))
+ ;; GUI or headless mode
+ (some->
+ opts
+ (validate-symreg-opts)
+ (symreg/run-app-from-cli-args)))))
diff --git a/src/closyr/dataset/inputs.clj b/src/closyr/dataset/inputs.clj
index 65554efd..91fe929e 100644
--- a/src/closyr/dataset/inputs.clj
+++ b/src/closyr/dataset/inputs.clj
@@ -1,7 +1,9 @@
(ns closyr.dataset.inputs
+ (:refer-clojure :exclude [rand rand-int rand-nth shuffle])
(:require
[closyr.dataset.prime-10000 :as data-primes]
- [closyr.dataset.prime-counting :as data-prime-counting]))
+ [closyr.dataset.prime-counting :as data-prime-counting]
+ [closyr.util.prng :refer [rand rand-int rand-nth shuffle]]))
(set! *warn-on-reflection* true)
@@ -23,65 +25,198 @@
"Functions to display in GUI which can be used as input data"
[sketchpad-size* sketch-input-x-count*]
{initial-fn
- {:idx 0
- :fn (fn [i]
- (y->gui-coord-y
- sketchpad-size*
- (+ (* 50 (Math/sin (/ i 4.0)))
- (* 30 (Math/cos (/ i 3.0))))))}
+ {:idx 0
+ :formula "50*Sin(13*x) + 30*Cos(19*x)"
+ :fn (fn [i]
+ (y->gui-coord-y
+ sketchpad-size*
+ (+ (* 50 (Math/sin (* 13.0 (/ i @sketch-input-x-count*))))
+ (* 30 (Math/cos (* 19.0 (/ i @sketch-input-x-count*)))))))}
"sin+cos 2"
- {:idx 5
- :fn (fn [i]
- (y->gui-coord-y
- sketchpad-size*
- (+ (* 10 (Math/sin (/ i 1.125)))
- (* 50 (Math/cos (/ i 5.0))))))}
+ {:idx 5
+ :formula "10*Sin(17*x) + 50*Cos(5*x)"
+ :fn (fn [i]
+ (y->gui-coord-y
+ sketchpad-size*
+ (+ (* 10 (Math/sin (* 17.0 (/ i @sketch-input-x-count*))))
+ (* 50 (Math/cos (* 5.0 (/ i @sketch-input-x-count*)))))))}
"cos"
- {:idx 10
- :fn (fn [i]
- (y->gui-coord-y
- sketchpad-size*
- (* 80 (Math/cos (/ i 3.0)))))}
+ {:idx 10
+ :formula "80*Cos(12*x)"
+ :fn (fn [i]
+ (y->gui-coord-y
+ sketchpad-size*
+ (* 80 (Math/cos (* 12.0 (/ i @sketch-input-x-count*))))))}
"sin"
- {:idx 20
- :fn (fn [i]
- (y->gui-coord-y
- sketchpad-size*
- (* 80 (Math/sin (/ i 3.0)))))}
+ {:idx 20
+ :formula "80*Sin(12*x)"
+ :fn (fn [i]
+ (y->gui-coord-y
+ sketchpad-size*
+ (* 80 (Math/sin (* 12.0 (/ i @sketch-input-x-count*))))))}
"log"
- {:idx 30
- :fn (fn [i]
- (y->gui-coord-y
- sketchpad-size*
- (* 50 (Math/log (+ 0.01 (/ i 4.0))))))}
+ {:idx 30
+ :formula "10*Log(0.01 + x)"
+ :fn (fn [i]
+ (y->gui-coord-y
+ sketchpad-size*
+ (* 10 (Math/log (+ 0.01 (/ i @sketch-input-x-count*))))))}
"hline"
- {:idx 40
- :fn (fn [i] (y->gui-coord-y sketchpad-size* 0.0))}
+ {:idx 40
+ :formula "0"
+ :fn (fn [i] (y->gui-coord-y sketchpad-size* 0.0))}
"prime count"
- {:idx 50
- :fn (fn [i]
- (let [xys (data-prime-counting/get-data @sketch-input-x-count*)]
- (y->gui-coord-y sketchpad-size* (second (nth xys i)))))}
+ {:idx 50
+ :formula "PrimePi(x)"
+ :fn (fn [i]
+ (let [xys (data-prime-counting/get-data @sketch-input-x-count*)]
+ (y->gui-coord-y sketchpad-size* (second (nth xys i)))))}
"primes"
- {:idx 60
- :fn (fn [i]
- (let [xys (data-primes/get-data @sketch-input-x-count*)]
- (y->gui-coord-y sketchpad-size* (second (nth xys i)))))}
+ {:idx 60
+ :formula "Prime(x)"
+ :fn (fn [i]
+ (let [xys (data-primes/get-data @sketch-input-x-count*)]
+ (y->gui-coord-y sketchpad-size* (second (nth xys i)))))}
"gaussian"
- {:idx 70
- :fn (fn [i]
- (y->gui-coord-y
- sketchpad-size*
- (* 40
- (Math/sqrt (* 2.0 Math/PI))
- (Math/exp (- (* (/ (/ (- i (/ @sketch-input-x-count* 2)) 5.0) 2.0)
- (/ (- i (/ @sketch-input-x-count* 2)) 5.0)))))))}
+ {:idx 70
+ :formula "Sqrt(2*Pi)*Exp(-((x-mu)/sigma)^2/2)"
+ :fn (fn [i]
+ (y->gui-coord-y
+ sketchpad-size*
+ (* 40
+ (Math/sqrt (* 2.0 Math/PI))
+ (Math/exp (- (* (/ (/ (- i (/ @sketch-input-x-count* 2)) 5.0) 2.0)
+ (/ (- i (/ @sketch-input-x-count* 2)) 5.0)))))))}
"random"
- {:idx 80
- :fn (fn [i]
- (y->gui-coord-y
- sketchpad-size*
- (* 60
- (rand))))}})
+ {:idx 80
+ :formula "Random()"
+ :fn (fn [i]
+ (y->gui-coord-y
+ sketchpad-size*
+ (* 60
+ (rand))))}
+
+ ;; Nguyen-4: x^6 + x^5 + x^4 + x^3 + x^2 + x , x in [-1, 1]
+ "Nguyen-4"
+ {:idx 90
+ :formula "x^6 + x^5 + x^4 + x^3 + x^2 + x"
+ :fn (fn [i]
+ (let [x (- (* 2.0 (/ i (double @sketch-input-x-count*))) 1.0)] ; map to [-1, 1]
+ (y->gui-coord-y
+ sketchpad-size*
+ (* 15 (+ (Math/pow x 6)
+ (Math/pow x 5)
+ (Math/pow x 4)
+ (Math/pow x 3)
+ (Math/pow x 2)
+ x)))))}
+
+ ;; Nguyen-5: Sin(x^2)*Cos(x) - 1 , x in [-1, 1]
+ "Nguyen-5"
+ {:idx 100
+ :formula "Sin(x^2)*Cos(x) - 1"
+ :fn (fn [i]
+ (let [x (- (* 2.0 (/ i (double @sketch-input-x-count*))) 1.0)] ; map to [-1, 1]
+ (y->gui-coord-y
+ sketchpad-size*
+ (* 40 (- (* (Math/sin (* x x))
+ (Math/cos x))
+ 1)))))}
+
+ ;; Lorentz factor: 1/Sqrt(1 - v^2/c^2), v/c in [0, 0.95]
+ "Feynman Lorentz"
+ {:idx 110
+ :formula "1/Sqrt(1 - x^2)"
+ :fn (fn [i]
+ (let [v-over-c (* 0.95 (/ i (double @sketch-input-x-count*)))] ; map to [0, 0.95]
+ (y->gui-coord-y
+ sketchpad-size*
+ (* 25 (/ 1.0
+ (Math/sqrt (- 1.0 (* v-over-c v-over-c))))))))}
+
+ ;; Wave equation: A*Sin(k*x - omega*t)
+ "Feynman Wave"
+ {:idx 120
+ :formula "A*Sin(k*x - omega*t)"
+ :fn (fn [i]
+ (let [x (* 12.0 (/ i (double @sketch-input-x-count*))) ; spatial coordinate
+ k 1.0 ; wave number
+ omega 0.5 ; angular frequency
+ t 2.0] ; fixed time
+ (y->gui-coord-y
+ sketchpad-size*
+ (* 60 (Math/sin (- (* k x) (* omega t)))))))}
+
+ ;; Diffraction grating: I = I0 * sin²(nθ/2) / sin²(θ/2), n=5
+ "Feynman Diffraction"
+ {:idx 130
+ :formula "Sin(n*x/2)^2 / Sin(x/2)^2"
+ :fn (fn [i]
+ (let [theta (+ 0.1 (* 6.0 (/ i (double @sketch-input-x-count*)))) ; θ in [0.1, 6.1]
+ n 5.0
+ half-theta (/ theta 2.0)
+ sin-half (Math/sin half-theta)
+ sin-n-half (Math/sin (* n half-theta))
+ intensity (if (< (Math/abs sin-half) 1e-10)
+ (* n n)
+ (/ (* sin-n-half sin-n-half)
+ (* sin-half sin-half)))]
+ (y->gui-coord-y
+ sketchpad-size*
+ (* 3 intensity))))}
+
+ ;; Planck radiation spectrum: x³ / (exp(x) - 1)
+ "Feynman Planck"
+ {:idx 140
+ :formula "x^3 / (Exp(x) - 1)"
+ :fn (fn [i]
+ (let [x (+ 0.1 (* 5.0 (/ i (double @sketch-input-x-count*)))) ; x in [0.1, 5.1]
+ planck (/ (* x x x)
+ (- (Math/exp x) 1.0))]
+ (y->gui-coord-y
+ sketchpad-size*
+ (* 50 planck))))}
+
+ ;; Rutherford scattering: 1 / sin⁴(θ/2)
+ "Feynman Rutherford"
+ {:idx 150
+ :formula "1 / Sin(x/2)^4"
+ :fn (fn [i]
+ (let [theta (+ 0.3 (* 2.8 (/ i (double @sketch-input-x-count*)))) ; θ in [0.3, 3.1]
+ sin-half (Math/sin (/ theta 2.0))
+ rutherford (/ 1.0
+ (* sin-half sin-half sin-half sin-half))]
+ (y->gui-coord-y
+ sketchpad-size*
+ (* 1 (Math/log rutherford)))))} ; use log scale for display
+
+ ;; Elliptical orbit: r = a(1-e²) / (1 + e*cos(θ)), e=0.6
+ "Feynman Ellipse"
+ {:idx 160
+ :formula "a*(1-e^2) / (1 + e*Cos(x))"
+ :fn (fn [i]
+ (let [theta (* 2.0 Math/PI (/ i (double @sketch-input-x-count*))) ; θ in [0, 2π]
+ e 0.6
+ a 1.0
+ radius (/ (* a (- 1.0 (* e e)))
+ (+ 1.0 (* e (Math/cos theta))))]
+ (y->gui-coord-y
+ sketchpad-size*
+ (* 60 radius))))}
+
+ ;; Quantum transition probability (sinc² function): sin²(x) / x²
+ ;; From Feynman III.9.52: PI→II = (2πμEt/h)² × sin²((ω-ω₀)t/2) / ((ω-ω₀)t/2)²
+ "Feynman Transition"
+ {:idx 170
+ :formula "Sin(x)^2 / x^2"
+ :fn (fn [i]
+ (let [x (+ -9.0 (* 18.0 (/ i (double @sketch-input-x-count*)))) ; x in [-9, 9]
+ sinc-sq (if (< (Math/abs x) 1e-10)
+ 1.0 ; limit as x->0 is 1
+ (/ (* (Math/sin x) (Math/sin x))
+ (* x x)))]
+ (y->gui-coord-y
+ sketchpad-size*
+ (* 80 sinc-sq))))}})
\ No newline at end of file
diff --git a/src/closyr/ga.clj b/src/closyr/ga.clj
index da6504c3..56dc4669 100644
--- a/src/closyr/ga.clj
+++ b/src/closyr/ga.clj
@@ -1,13 +1,36 @@
(ns closyr.ga
(:refer-clojure :exclude [rand rand-int rand-nth shuffle])
(:require
+ [closyr.adaptive :as adaptive]
[closyr.util.log :as log]
- [closyr.util.prng :refer :all]))
+ [closyr.util.prng :refer [rand rand-int rand-nth shuffle shuffle-arraylist!]])
+ (:import
+ (java.util ArrayList List)))
(set! *warn-on-reflection* true)
+(def ^:dynamic *deterministic-mode*
+ "When true, uses sequential map instead of pmap for deterministic results.
+ Set to true when using a seeded PRNG for reproducibility."
+ false)
+
+
+(def ^:dynamic *adaptive-mode*
+ "When true, uses adaptive mutation rates based on population diversity and stagnation.
+ When false, uses fixed 80/20 mutation/crossover ratio."
+ true)
+
+
+(defn- maybe-pmap
+ "Uses pmap for parallel execution unless *deterministic-mode* is true."
+ [f coll]
+ (if *deterministic-mode*
+ (mapv f coll)
+ (pmap f coll)))
+
+
(defn initialize
"Initialize GA population and functions"
[initial-pop score-fn mutation-fn crossover-fn]
@@ -18,18 +41,45 @@
(def ^:private new-phen-modifier-sampler
- ;; 4 / 5 chance of mutation instead of crossover:
+ ;; 4 / 5 chance of mutation instead of crossover (used when adaptive mode is off):
[true true true true false])
+(defn- should-use-mutation?
+ "Decide whether to use mutation or crossover.
+ Uses adaptive probability when *adaptive-mode* is true, otherwise fixed 80/20."
+ []
+ (if *adaptive-mode*
+ (adaptive/should-mutate?)
+ (rand-nth new-phen-modifier-sampler)))
+
+
+(def ^:private min-score
+ "Minimum score for phenotypes that fail to evaluate"
+ -100000000)
+
+
(defn- with-score
+ "Score a phenotype, catching any exceptions and returning min-score on failure.
+ Re-throws InterruptedException to allow job cancellation."
[the-score-fn p]
(if (:score p)
p
- (assoc p :score (the-score-fn p))))
+ (try
+ ;; Check for interruption before expensive scoring
+ (when (Thread/interrupted)
+ (throw (InterruptedException. "Scoring interrupted")))
+ (assoc p :score (the-score-fn p))
+ (catch InterruptedException e
+ (throw e)) ; Re-throw to propagate interruption
+ (catch Exception e
+ (log/warn "Scoring failed for phenotype, using min-score:" (.getMessage e))
+ (assoc p :score min-score)))))
(defn- compete
+ "Compete two phenotypes and evolve the winner.
+ Catches any exceptions during mutation/crossover to prevent crashes."
[{:keys [pop score-fn mutation-fn crossover-fn]
:as config}
[{^double e1-score :score :as e1} {^double e2-score :score :as e2}]]
@@ -37,13 +87,18 @@
(if (nil? e2)
[e1-score [e1]]
- (let [new-e-fn (if (rand-nth new-phen-modifier-sampler)
- mutation-fn
- crossover-fn)
- next-e (if (>= e1-score e2-score)
- (with-score score-fn (new-e-fn e1 e2))
- e2)]
- [(+ e1-score e2-score) [e1 next-e]])))
+ (try
+ (let [new-e-fn (if (should-use-mutation?)
+ mutation-fn
+ crossover-fn)
+ next-e (if (>= e1-score e2-score)
+ (with-score score-fn (new-e-fn e1 e2))
+ e2)]
+ [(+ e1-score e2-score) [e1 next-e]])
+ (catch Exception e
+ (log/warn "Competition failed, keeping both parents:" (.getMessage e))
+ ;; On failure, just keep both parents unchanged
+ [(+ e1-score e2-score) [e1 e2]]))))
(defn- pop->chunks
@@ -55,34 +110,83 @@
:else 10))
+(defn- process-chunk
+ "Process a chunk of population pairs, returning [scores new-individuals]."
+ [config ^List chunk]
+ (let [chunk-size (.size chunk)
+ scores (transient [])
+ new-pop (transient [])]
+ (loop [i 0]
+ (when (< i chunk-size)
+ (let [e1 (.get chunk i)
+ e2 (when (< (inc i) chunk-size) (.get chunk (inc i)))
+ [pair-score pair-result] (compete config [e1 e2])]
+ (conj! scores pair-score)
+ (run! #(conj! new-pop %) pair-result)
+ (recur (+ i 2)))))
+ [(persistent! scores) (persistent! new-pop)]))
+
+
+(defn- process-chunks-parallel
+ "Process population chunks in parallel, collecting scores and new population."
+ [config ^ArrayList shuffled-pop chunk-size]
+ (let [pop-size (.size shuffled-pop)
+ num-chunks (Math/ceil (/ pop-size (double chunk-size)))
+ chunk-ranges (mapv (fn [i]
+ (let [start (* i chunk-size)
+ end (min (* (inc i) chunk-size) pop-size)]
+ [start end]))
+ (range (int num-chunks)))
+ ;; Create sub-lists (views, no copy) for each chunk
+ chunks (mapv (fn [[start end]]
+ (.subList shuffled-pop start end))
+ chunk-ranges)
+ ;; Process chunks in parallel
+ results (if *deterministic-mode*
+ (mapv (partial process-chunk config) chunks)
+ (pmap (partial process-chunk config) chunks))]
+ ;; Combine results using transducers
+ (let [all-scores (into [] (mapcat first) results)
+ all-pop (into [] (mapcat second) results)]
+ [all-scores all-pop])))
+
+
+(defn- check-interrupted!
+ "Check if current thread has been interrupted and throw if so.
+ This allows jobs to be stopped more quickly during evolution."
+ []
+ (when (Thread/interrupted)
+ (throw (InterruptedException. "Evolution interrupted"))))
+
+
(defn evolve
- "Evolve a population using random competition"
+ "Evolve a population using random competition.
+ Optimized to reduce intermediate allocations using transducers and in-place operations.
+ Checks for thread interruption to allow jobs to be stopped cleanly."
[{:keys [pop score-fn mutation-fn crossover-fn]
:as config}]
(try
- (let [pop-shuff (->>
- pop
- (pmap (partial with-score score-fn))
- (shuffle))
-
- new-pop-data (->>
- (partition-all (pop->chunks pop) pop-shuff)
- (pmap (fn [pop-chunk]
- (mapv (partial compete config)
- (partition-all 2 pop-chunk))))
- (mapcat identity))
-
- pop-scores (vec (pmap first new-pop-data))
- new-pop (->> (pmap second new-pop-data)
- (mapcat identity)
- (vec))]
-
- (merge config
- {:pop new-pop
- :score-fn score-fn
- :pop-scores pop-scores
- :mutation-fn mutation-fn
- :crossover-fn crossover-fn}))
+ ;; Check for interruption before starting expensive work
+ (check-interrupted!)
+ (let [;; Score population (parallel if not deterministic)
+ scored-pop (if *deterministic-mode*
+ (mapv (partial with-score score-fn) pop)
+ (into [] (pmap (partial with-score score-fn) pop)))
+ ;; Check again after scoring
+ _ (check-interrupted!)
+ ;; Shuffle in-place, returning ArrayList for efficient indexed access
+ shuffled (shuffle-arraylist! scored-pop)
+ ;; Process in chunks
+ chunk-size (pop->chunks pop)
+ [pop-scores new-pop] (process-chunks-parallel config shuffled chunk-size)]
+ ;; Final check before returning
+ (check-interrupted!)
+ (assoc config
+ :pop new-pop
+ :pop-scores pop-scores))
+ (catch InterruptedException e
+ (log/info "Evolution interrupted by user")
+ (throw e))
(catch Exception e
(log/error "Err in evolve: " e)
(throw e))))
diff --git a/src/closyr/ops.clj b/src/closyr/ops.clj
index da687c68..b9c9f01d 100644
--- a/src/closyr/ops.clj
+++ b/src/closyr/ops.clj
@@ -3,11 +3,12 @@
(:require
[clojure.core.async :as async :refer [go go-loop timeout !! ! chan put! take! alts!! alts! close!]]
[clojure.string :as str]
+ [closyr.adaptive :as adaptive]
[closyr.ops.common :as ops-common]
[closyr.ops.eval :as ops-eval]
[closyr.ops.modify :as ops-modify]
[closyr.util.log :as log]
- [closyr.util.prng :refer :all]
+ [closyr.util.prng :refer [rand rand-int rand-nth shuffle]]
[closyr.util.spec :as specs])
(:import
(java.text
@@ -26,6 +27,64 @@
-100000000)
+;; =============================================================================
+;; Evaluation Cache
+;; =============================================================================
+
+(def ^:dynamic *use-eval-cache*
+ "When true, cache evaluation results by expression string.
+ Can significantly speed up evolution when duplicate expressions appear."
+ false)
+
+
+(def ^:dynamic *scoring-method*
+ "Scoring method to use for fitness evaluation. All methods return 0 for perfect fit.
+ - :mae-max (default) - Negative of (2*MAE + max_residual). Traditional approach.
+ - :log-cosh - Log-cosh loss. Smooth like MSE for small errors, robust like MAE for large.
+ - :r-squared - (R² - 1), so perfect fit = 0, worse fits are negative."
+ :mae-max)
+
+
+(def ^:dynamic *simplicity-bias*
+ "Simplicity bias level for preferring smaller formulas. Applied as a score deduction
+ proportional to formula complexity (leaf count squared).
+ - :none - No bias, only raw score matters
+ - :tiebreaker (default) - Tiny deduction, just breaks ties for same scores
+ - :light - Light preference for simpler formulas
+ - :strong - Strong preference for simpler formulas"
+ :tiebreaker)
+
+
+(def ^:private simplicity-bias-config
+ "Configuration for each simplicity bias level.
+ :multiplier - Base multiplier for complexity penalty (higher = stronger preference for simpler formulas)
+ :cap - Maximum fraction of score that can be deducted (e.g., 0.1 = max 10% deduction)"
+ {:none {:multiplier 0.0 :cap 0.0}
+ :tiebreaker {:multiplier 0.0000001 :cap 0.1}
+ :light {:multiplier 0.00001 :cap 0.2}
+ :strong {:multiplier 0.001 :cap 2.0}})
+
+
+;; Cache of expression string -> score. Reset between runs.
+(defonce eval-cache*
+ (atom {}))
+
+
+(defn clear-eval-cache!
+ "Clear the evaluation cache. Call this before starting a new run."
+ []
+ (reset! eval-cache* {}))
+
+
+(defn eval-cache-stats
+ "Return stats about the evaluation cache."
+ []
+ (let [cache @eval-cache*]
+ {:size (count cache)
+ :hits (:hits (meta cache) 0)
+ :misses (:misses (meta cache) 0)}))
+
+
(def default-max-leafs
"Default max number of AST tree leafs in candidate pheno function"
40)
@@ -90,59 +149,376 @@
(min max-resid (abs res)))))
+(defn- compute-residuals-fast
+ "Compute sum and max of residuals using primitive arrays.
+ Returns [sum-residuals max-residual] or nil if f-of-xs is invalid.
+ ~50x faster than map/reduce approach."
+ ^doubles [^doubles ys-arr f-of-xs]
+ (when (and f-of-xs (seq f-of-xs))
+ (let [n (count f-of-xs)
+ max-r (double max-resid)]
+ (loop [i (int 0), sum (double 0.0), mx (double 0.0)]
+ (if (< i n)
+ (let [expected (aget ys-arr i)
+ actual (double (nth f-of-xs i))
+ resid (if (or (Double/isNaN actual) (Double/isInfinite actual))
+ max-r
+ (Math/abs (- expected actual)))
+ resid (Math/min max-r resid)]
+ (recur (unchecked-inc-int i)
+ (+ sum resid)
+ (Math/max mx resid)))
+ (double-array [sum mx (double n)]))))))
+
+
(defn- length-deduction
+ "A score deduction based on the number of leafs, proportional to score.
+ The deduction magnitude is controlled by *simplicity-bias*.
+ - :none - returns 0 (no deduction)
+ - :tiebreaker - tiny deduction, just breaks ties
+ - :light - light preference for simpler formulas
+ - :strong - strong preference for simpler formulas"
[score leafs]
- (* (abs score) (min 0.1 (* 0.0000001 leafs leafs))))
+ (let [{:keys [multiplier cap]} (get simplicity-bias-config *simplicity-bias*
+ {:multiplier 0.0000001 :cap 0.1})]
+ (if (zero? multiplier)
+ 0.0
+ (* (abs score) (min cap (* multiplier leafs #_leafs (+ 1.0 (Math/log (+ leafs 1)))))))))
+
+
+;; =============================================================================
+;; Alternative Scoring Methods
+;; =============================================================================
+
+(defn- log-cosh
+ "Compute log(cosh(x)), numerically stable for large x.
+ For large |x|, log(cosh(x)) ≈ |x| - log(2)"
+ ^double [^double x]
+ (let [abs-x (Math/abs x)]
+ (if (> abs-x 20.0)
+ ;; For large values, use approximation to avoid overflow
+ (- abs-x 0.6931471805599453) ; log(2)
+ (Math/log (Math/cosh x)))))
+
+
+(defn- compute-log-cosh-score
+ "Compute score using log-cosh loss. Smooth like MSE for small errors,
+ robust like MAE for large errors. No hyperparameter tuning needed.
+ Returns [log-cosh-sum max-residual n] or nil if invalid."
+ ^doubles [^doubles ys-arr f-of-xs]
+ (when (and f-of-xs (seq f-of-xs))
+ (let [n (count f-of-xs)
+ max-r (double max-resid)]
+ (loop [i (int 0), sum (double 0.0), mx (double 0.0)]
+ (if (< i n)
+ (let [expected (aget ys-arr i)
+ actual (double (nth f-of-xs i))
+ resid (if (or (Double/isNaN actual) (Double/isInfinite actual))
+ max-r
+ (- expected actual))
+ resid-clamped (Math/min max-r (Math/abs resid))
+ lc (log-cosh resid)]
+ (recur (unchecked-inc-int i)
+ (+ sum lc)
+ (Math/max mx resid-clamped)))
+ (double-array [sum mx (double n)]))))))
+
+
+(defn- compute-r-squared-score
+ "Compute R² (coefficient of determination) score.
+ R² = 1 - (SS_res / SS_tot) where:
+ - SS_res = sum of squared residuals
+ - SS_tot = total sum of squares (variance from mean)
+ Returns R² value (ideally 1.0, can be negative for poor fits)."
+ ^double [^doubles ys-arr f-of-xs]
+ (when (and f-of-xs (seq f-of-xs))
+ (let [n (count f-of-xs)
+ max-r (double max-resid)
+ ;; First pass: compute mean of ys and sum of squared residuals
+ y-sum (loop [i (int 0), s (double 0.0)]
+ (if (< i n)
+ (recur (unchecked-inc-int i) (+ s (aget ys-arr i)))
+ s))
+ y-mean (/ y-sum n)
+ ;; Compute SS_res and SS_tot in one pass
+ [ss-res ss-tot]
+ (loop [i (int 0), ss-res (double 0.0), ss-tot (double 0.0)]
+ (if (< i n)
+ (let [expected (aget ys-arr i)
+ actual (double (nth f-of-xs i))
+ resid (if (or (Double/isNaN actual) (Double/isInfinite actual))
+ max-r
+ (- expected actual))
+ resid-sq (* resid resid)
+ tot-diff (- expected y-mean)
+ tot-sq (* tot-diff tot-diff)]
+ (recur (unchecked-inc-int i)
+ (+ ss-res resid-sq)
+ (+ ss-tot tot-sq)))
+ [ss-res ss-tot]))]
+ (if (< ss-tot 1e-10)
+ ;; If total variance is ~0, all ys are the same
+ ;; Return 1.0 if predictions are also constant and close, else 0
+ (if (< ss-res 1e-10) 1.0 0.0)
+ (- 1.0 (/ ss-res ss-tot))))))
(defn compute-score-from-actuals-and-expecteds
- "Compute overall score for fn given some actual and expected ys"
- {:malli/schema [:=> [:cat #'specs/GAPhenotype #'specs/NumberVector #'specs/NumberVector number?] number?]}
- [pheno f-of-xs input-ys-vec leafs]
- (try
- (let [abs-resids (map compute-residual input-ys-vec f-of-xs)
- resid-sum (sum abs-resids)
- score (* -1.0 (+ (* 2.0 (/ resid-sum (count abs-resids)))
- (reduce max abs-resids)))
- length-deduction (length-deduction score leafs)
- overall-score (- score length-deduction)]
-
- (swap! sim-stats* update-in [:scoring :len-deductions] #(into (or % []) [length-deduction]))
-
- overall-score)
- (catch Exception e
- (log/error "Err in computing score from residuals: "
- (.getMessage e) ", fn: " (str (:expr pheno)) ", from: " (:expr pheno))
- (tally-min-score min-score))))
+ "Compute overall score for fn given some actual and expected ys.
+ Uses fast primitive array computation when ys-arr is provided.
+ All scoring methods return 0 for perfect fit, negative for worse fits.
+
+ Scoring methods:
+ - :mae-max (default) - Negative of (2*MAE + max_residual). MAE = Mean Absolute Error.
+ - :log-cosh - Log-cosh loss. Smooth like MSE for small errors, robust like MAE for large.
+ - :r-squared - (R² - 1), so perfect fit = 0, worse fits are negative."
+ {:malli/schema [:function
+ [:=> [:cat #'specs/GAPhenotype #'specs/NumberVector #'specs/NumberVector number?] number?]
+ [:=> [:cat #'specs/GAPhenotype #'specs/NumberVector #'specs/NumberVector number? [:maybe some?]] number?]
+ [:=> [:cat #'specs/GAPhenotype #'specs/NumberVector #'specs/NumberVector number? [:maybe some?] keyword?] number?]]}
+ ([pheno f-of-xs input-ys-vec leafs]
+ (compute-score-from-actuals-and-expecteds pheno f-of-xs input-ys-vec leafs nil :mae-max))
+ ([pheno f-of-xs input-ys-vec leafs ^doubles input-ys-arr]
+ (compute-score-from-actuals-and-expecteds pheno f-of-xs input-ys-vec leafs input-ys-arr :mae-max))
+ ([pheno f-of-xs input-ys-vec leafs ^doubles input-ys-arr scoring-method]
+ (try
+ (case scoring-method
+ ;; R² scoring - returns (R² - 1), so perfect fit = 0, worse fits are negative
+ :r-squared
+ (if input-ys-arr
+ (let [r2 (compute-r-squared-score input-ys-arr f-of-xs)]
+ (if r2
+ (let [score (- r2 1.0) ;; Shift so perfect fit = 0
+ length-ded (length-deduction (abs score) leafs)]
+ (swap! sim-stats* update-in [:scoring :len-deductions] #(into (or % []) [length-ded]))
+ (- score length-ded))
+ (tally-min-score min-score)))
+ (tally-min-score min-score))
+
+ ;; Log-cosh scoring
+ :log-cosh
+ (if input-ys-arr
+ (let [^doubles result (compute-log-cosh-score input-ys-arr f-of-xs)]
+ (if result
+ (let [lc-sum (aget result 0)
+ n (aget result 2)
+ ;; Negative mean log-cosh (higher/less negative = better)
+ score (* -1.0 (/ lc-sum n))
+ length-ded (length-deduction score leafs)
+ overall-score (- score length-ded)]
+ (swap! sim-stats* update-in [:scoring :len-deductions] #(into (or % []) [length-ded]))
+ overall-score)
+ (tally-min-score min-score)))
+ (tally-min-score min-score))
+
+ ;; Default: MAE + max residual (original method)
+ (let [[resid-sum max-resid-val n]
+ (if input-ys-arr
+ ;; Fast path with primitive array
+ (let [^doubles result (compute-residuals-fast input-ys-arr f-of-xs)]
+ (when result
+ [(aget result 0) (aget result 1) (aget result 2)]))
+ ;; Fallback to original implementation
+ (let [abs-resids (map compute-residual input-ys-vec f-of-xs)]
+ [(sum abs-resids) (reduce max abs-resids) (count abs-resids)]))]
+ (if resid-sum
+ (let [score (* -1.0 (+ (* 2.0 (/ resid-sum n))
+ max-resid-val))
+ length-ded (length-deduction score leafs)
+ overall-score (- score length-ded)]
+ (swap! sim-stats* update-in [:scoring :len-deductions] #(into (or % []) [length-ded]))
+ overall-score)
+ (tally-min-score min-score))))
+ (catch Exception e
+ (log/error "Err in computing score from residuals: "
+ (.getMessage e) ", fn: " (str (:expr pheno)) ", from: " (:expr pheno))
+ (tally-min-score min-score)))))
+
+
+(defn- score-fn-uncached
+ "Core scoring logic without caching."
+ [{:keys [input-xs-list input-xs-count input-ys-vec input-ys-arr]
+ :as run-args}
+ {:keys [max-leafs scoring-method]}
+ pheno
+ expr-str]
+ ;; Skip Hold() expressions - they can't be numerically evaluated
+ (if (str/starts-with? expr-str "Hold(")
+ (tally-min-score min-score)
+ (let [leafs (.leafCount ^IExpr (:expr pheno))]
+ (if (> leafs max-leafs)
+ (tally-min-score min-score)
+ (let [f-of-xs (ops-eval/eval-vec-pheno pheno run-args)]
+ (if f-of-xs
+ (compute-score-from-actuals-and-expecteds
+ pheno f-of-xs input-ys-vec leafs input-ys-arr (or scoring-method *scoring-method*))
+ (tally-min-score min-score)))))))
(defn score-fn
- "Symbolic regression scoring"
+ "Symbolic regression scoring.
+ Uses primitive array for fast residual computation when input-ys-arr is available.
+ When *use-eval-cache* is true, caches results by expression string.
+ All scoring methods return 0 for perfect fit, negative for worse fits.
+
+ Supports configurable scoring methods via :scoring-method in run-config:
+ - :mae-max (default) - Negative of (2*MAE + max_residual)
+ - :log-cosh - Log-cosh loss, robust to outliers
+ - :r-squared - (R² - 1), perfect fit = 0"
{:malli/schema [:=> [:cat #'specs/ScoreFnArgs [:map {:closed false} [:max-leafs number?]] #'specs/GAPhenotype] number?]}
- [{:keys [input-xs-list input-xs-count input-ys-vec]
+ [{:keys [input-xs-list input-xs-count input-ys-vec input-ys-arr]
:as run-args}
- {:keys [max-leafs]}
+ {:keys [max-leafs] :as run-config}
pheno]
(try
- (let [leafs (.leafCount ^IExpr (:expr pheno))]
- (if (> leafs max-leafs)
- (tally-min-score min-score)
- (let [f-of-xs (ops-eval/eval-vec-pheno pheno run-args)]
- (if f-of-xs
- (compute-score-from-actuals-and-expecteds pheno f-of-xs input-ys-vec leafs)
- (tally-min-score min-score)))))
+ (let [expr-str (str (:expr pheno))
+ ;; Include scoring method in cache key to prevent cross-contamination
+ ;; between concurrent jobs using different scoring methods
+ effective-scoring-method (or (:scoring-method run-config) *scoring-method*)
+ effective-simplicity-bias (or (:simplicity-bias run-config) *simplicity-bias*)
+ cache-key [expr-str effective-scoring-method effective-simplicity-bias]]
+ (if *use-eval-cache*
+ ;; Cached path
+ (if-let [cached-score (get @eval-cache* cache-key)]
+ (do
+ (swap! eval-cache* vary-meta update :hits (fnil inc 0))
+ cached-score)
+ (let [score (score-fn-uncached run-args run-config pheno expr-str)]
+ (swap! eval-cache* (fn [c]
+ (-> (assoc c cache-key score)
+ (vary-meta update :misses (fnil inc 0)))))
+ score))
+ ;; Uncached path
+ (score-fn-uncached run-args run-config pheno expr-str)))
(catch Exception e
- (log/error "Err in score fn: " (.getMessage e) ", fn: " (str (:expr pheno)) ", from: " (:expr pheno))
+ (log/warn "Err in score fn: " (.getMessage e) ", fn: " (str (:expr pheno)) ", from: " (:expr pheno))
(tally-min-score min-score))))
+(defn- compute-raw-score-for-method
+ "Compute raw score (without length deduction) for a single scoring method.
+ Returns the raw score or min-score if computation fails."
+ [f-of-xs input-ys-vec leafs ^doubles input-ys-arr scoring-method]
+ (try
+ (case scoring-method
+ :r-squared
+ (if input-ys-arr
+ (let [r2 (compute-r-squared-score input-ys-arr f-of-xs)]
+ (if r2
+ (- r2 1.0) ;; Shift so perfect fit = 0
+ min-score))
+ min-score)
+
+ :log-cosh
+ (if input-ys-arr
+ (let [^doubles result (compute-log-cosh-score input-ys-arr f-of-xs)]
+ (if result
+ (let [lc-sum (aget result 0)
+ n (aget result 2)]
+ (* -1.0 (/ lc-sum n)))
+ min-score))
+ min-score)
+
+ ;; Default: MAE + max residual
+ (if input-ys-arr
+ (let [^doubles result (compute-residuals-fast input-ys-arr f-of-xs)]
+ (if result
+ (let [resid-sum (aget result 0)
+ max-resid-val (aget result 1)
+ n (aget result 2)]
+ (* -1.0 (+ (* 2.0 (/ resid-sum n)) max-resid-val)))
+ min-score))
+ min-score))
+ (catch Exception _
+ min-score)))
+
+
+(defn compute-all-method-scores
+ "Compute scores for all three scoring methods for a phenotype.
+ Returns a map of {:mae-max score, :log-cosh score, :r-squared score}.
+ Useful for displaying alternative scores in job results."
+ [{:keys [input-xs-list input-xs-count input-ys-vec input-ys-arr] :as run-args}
+ {:keys [max-leafs] :as run-config}
+ pheno]
+ (try
+ (let [expr-str (str (:expr pheno))]
+ (if (str/starts-with? expr-str "Hold(")
+ {:mae-max min-score :log-cosh min-score :r-squared min-score}
+ (let [leafs (.leafCount ^IExpr (:expr pheno))]
+ (if (> leafs (or max-leafs default-max-leafs))
+ {:mae-max min-score :log-cosh min-score :r-squared min-score}
+ (let [f-of-xs (ops-eval/eval-vec-pheno pheno run-args)]
+ (if f-of-xs
+ {:mae-max (compute-score-from-actuals-and-expecteds
+ pheno f-of-xs input-ys-vec leafs input-ys-arr :mae-max)
+ :log-cosh (compute-score-from-actuals-and-expecteds
+ pheno f-of-xs input-ys-vec leafs input-ys-arr :log-cosh)
+ :r-squared (compute-score-from-actuals-and-expecteds
+ pheno f-of-xs input-ys-vec leafs input-ys-arr :r-squared)}
+ {:mae-max min-score :log-cosh min-score :r-squared min-score}))))))
+ (catch Exception e
+ (log/warn "Err computing all scores: " (.getMessage e) ", fn: " (str (:expr pheno)))
+ {:mae-max min-score :log-cosh min-score :r-squared min-score})))
+
+
+(defn compute-all-method-scores-detailed
+ "Compute scores for all three scoring methods for a phenotype, with raw scores and deductions.
+ Returns a map with:
+ - :scores - scores with length deduction applied (for GA optimization)
+ - :raw-scores - scores without length deduction (for fair comparison across jobs)
+ - :length-deductions - the deduction amounts per method
+ This allows comparing jobs with different simplicity bias settings."
+ [{:keys [input-xs-list input-xs-count input-ys-vec input-ys-arr] :as run-args}
+ {:keys [max-leafs] :as run-config}
+ pheno]
+ (try
+ (let [expr-str (str (:expr pheno))]
+ (if (str/starts-with? expr-str "Hold(")
+ {:scores {:mae-max min-score :log-cosh min-score :r-squared min-score}
+ :raw-scores {:mae-max min-score :log-cosh min-score :r-squared min-score}
+ :length-deductions {:mae-max 0.0 :log-cosh 0.0 :r-squared 0.0}}
+ (let [leafs (.leafCount ^IExpr (:expr pheno))]
+ (if (> leafs (or max-leafs default-max-leafs))
+ {:scores {:mae-max min-score :log-cosh min-score :r-squared min-score}
+ :raw-scores {:mae-max min-score :log-cosh min-score :r-squared min-score}
+ :length-deductions {:mae-max 0.0 :log-cosh 0.0 :r-squared 0.0}}
+ (let [f-of-xs (ops-eval/eval-vec-pheno pheno run-args)]
+ (if f-of-xs
+ (let [;; Compute raw scores (without deduction)
+ raw-mae-max (compute-raw-score-for-method f-of-xs input-ys-vec leafs input-ys-arr :mae-max)
+ raw-log-cosh (compute-raw-score-for-method f-of-xs input-ys-vec leafs input-ys-arr :log-cosh)
+ raw-r-squared (compute-raw-score-for-method f-of-xs input-ys-vec leafs input-ys-arr :r-squared)
+ ;; Compute length deductions
+ ded-mae-max (length-deduction raw-mae-max leafs)
+ ded-log-cosh (length-deduction raw-log-cosh leafs)
+ ded-r-squared (length-deduction (abs raw-r-squared) leafs)]
+ {:scores {:mae-max (- raw-mae-max ded-mae-max)
+ :log-cosh (- raw-log-cosh ded-log-cosh)
+ :r-squared (- raw-r-squared ded-r-squared)}
+ :raw-scores {:mae-max raw-mae-max
+ :log-cosh raw-log-cosh
+ :r-squared raw-r-squared}
+ :length-deductions {:mae-max ded-mae-max
+ :log-cosh ded-log-cosh
+ :r-squared ded-r-squared}})
+ {:scores {:mae-max min-score :log-cosh min-score :r-squared min-score}
+ :raw-scores {:mae-max min-score :log-cosh min-score :r-squared min-score}
+ :length-deductions {:mae-max 0.0 :log-cosh 0.0 :r-squared 0.0}}))))))
+ (catch Exception e
+ (log/warn "Err computing all scores detailed: " (.getMessage e) ", fn: " (str (:expr pheno)))
+ {:scores {:mae-max min-score :log-cosh min-score :r-squared min-score}
+ :raw-scores {:mae-max min-score :log-cosh min-score :r-squared min-score}
+ :length-deductions {:mae-max 0.0 :log-cosh 0.0 :r-squared 0.0}})))
+
+
(def ^:dynamic *long-running-mutation-thresh-ms*
"If a mutation takes longer than this in ms, log info about it"
5000)
(defn mutation-fn
- "Symbolic regression mutation"
+ "Symbolic regression mutation.
+ Uses adaptive mutation count when adaptive mode is enabled."
{:malli/schema
[:=>
@@ -155,9 +531,9 @@
p-winner
p-discard]
(try
- (let [start (Date.)
+ (let [start (Date.)
{:keys [new-pheno iters mods]} (ops-modify/apply-modifications
- max-leafs (rand-nth ops-modify/mutations-sampler) initial-muts p-winner p-discard)
+ max-leafs (ops-modify/sample-mutation-count) initial-muts p-winner p-discard)
diff-ms (ops-common/start-date->diff-ms start)]
(when (> diff-ms *long-running-mutation-thresh-ms*)
@@ -202,15 +578,17 @@
(defn- reportable-phen-str
- [{:keys [^IExpr expr ^double score last-op mods-applied] p-id :id :as p}]
- (str
- " id: " (str/join (take 3 (str p-id)))
- " last mod #: " (or mods-applied "-")
- " last op: " (format "%18s" (str last-op))
- " score: " (.format score-format score)
- " leafs: " (.leafCount expr)
- ;; strip newlines from here also:
- " fn: " (format-fn-str expr)))
+ [{:keys [^IExpr expr score last-op mods-applied] p-id :id :as p}]
+ (if (and expr score)
+ (str
+ " id: " (str/join (take 3 (str p-id)))
+ " last mod #: " (or mods-applied "-")
+ " last op: " (format "%18s" (str last-op))
+ " score: " (.format score-format (double score))
+ " leafs: " (.leafCount expr)
+ ;; strip newlines from here also:
+ " fn: " (format-fn-str expr))
+ " [invalid phenotype]"))
(defn- summarize-sim-stats
@@ -272,50 +650,93 @@
(defn report-iteration
- "Print and maybe send to GUI a summary report of the population, including best fn/score/etc"
- [i
+ "Print and maybe send to GUI a summary report of the population, including best fn/score/etc.
+ Also calls progress-callback if provided in run-config.
+ Updates adaptive mutation state based on population metrics."
+ [iters-to-go
iters
ga-result
{:keys [input-xs-list input-xs-count input-ys-vec
sim-stop-start-chan sim->gui-chan extended-domain-args]
:as run-args}
- {:keys [use-gui? max-leafs] :as run-config}]
- (when (or (= 1 i) (zero? (mod i *log-steps*)))
- (let [bests (sort-population ga-result)
- took-s (/ (ops-common/start-date->diff-ms @test-timer*) 1000.0)
- pop-size (count (:pop ga-result))
- best-v (first bests)
- best-p99-v (nth bests (* 0.01 (count bests)))
- best-p95-v (nth bests (* 0.05 (count bests)))
- best-p90-v (nth bests (* 0.1 (count bests)))
- evaled (ops-eval/eval-vec-pheno best-v run-args)
+ {:keys [use-gui? max-leafs progress-callback scoring-method simplicity-bias] :as run-config}]
+ (when (or (= 1 iters-to-go) (zero? (mod iters-to-go *log-steps*)))
+ (let [bests (sort-population ga-result)
+ ;; Update adaptive state with current population scores
+ sorted-scores (mapv :score bests)
+ _ (adaptive/update-adaptive-state! sorted-scores)
+ timer @test-timer*
+ took-s (if timer
+ (/ (ops-common/start-date->diff-ms timer) 1000.0)
+ 0.0)
+ pop-size (count (:pop ga-result))
+ best-v (first bests)
+ n-bests (count bests)
+ best-p99-v (when (pos? n-bests) (nth bests (min (dec n-bests) (int (* 0.01 n-bests)))))
+ best-p95-v (when (pos? n-bests) (nth bests (min (dec n-bests) (int (* 0.05 n-bests)))))
+ best-p90-v (when (pos? n-bests) (nth bests (min (dec n-bests) (int (* 0.1 n-bests)))))
+ evaled (ops-eval/eval-vec-pheno best-v run-args)
{evaled-extended :ys xs-extended :xs} (ops-eval/eval-vec-pheno-oversample
- best-v run-args extended-domain-args)]
+ best-v run-args extended-domain-args)
+ current-iteration (inc (- iters iters-to-go))]
(reset! test-timer* (Date.))
- (log/info i "-step pop size: " pop-size
- " points: " (count input-ys-vec)
- " max leafs: " max-leafs
- " took secs: " took-s
- " phenos/s: " (Math/round ^double (/ (* pop-size *log-steps*) took-s))
- (str "\n top " *print-top-n* " best:\n"
- (->> (take *print-top-n* bests)
- (map reportable-phen-str)
- (str/join "\n")))
- "\n"
- (summarize-sim-stats))
+
+ ;; Log iteration details unless quiet-logs is set (e.g., when running from web API)
+ (when-not (:quiet-logs run-config)
+ (log/info current-iteration "-th-iter, "
+ " iters left: " (dec iters-to-go)
+ " pop size: " pop-size
+ " points: " (count input-ys-vec)
+ " max leafs: " max-leafs
+ " took secs: " took-s
+ " phenos/s: " (if (pos? took-s)
+ (Math/round ^double (/ (* pop-size *log-steps*) took-s))
+ 0)
+ (str "\n top " *print-top-n* " best:\n"
+ (->> (take *print-top-n* bests)
+ (map reportable-phen-str)
+ (str/join "\n")))
+ "\n"
+ (summarize-sim-stats)
+ "\n "
+ (adaptive/format-adaptive-status)))
(when use-gui?
(put! sim->gui-chan {:iters iters
- :i (inc (- iters i))
+ :i current-iteration
:best-eval evaled
:input-xs-vec-extended xs-extended
:best-eval-extended evaled-extended
:best-f-str (str (:expr best-v))
- :best-score (:score best-v)
- :best-p99-score (:score best-p99-v)
- :best-p95-score (:score best-p95-v)
- :best-p90-score (:score best-p90-v)}))))
+ :best-score (or (:score best-v) min-score)
+ :best-p99-score (or (:score best-p99-v) min-score)
+ :best-p95-score (or (:score best-p95-v) min-score)
+ :best-p90-score (or (:score best-p90-v) min-score)}))
+
+ ;; Call progress callback if provided (for HTTP API/SSE)
+ (when (and progress-callback best-v)
+ (try
+ (let [{:keys [scores raw-scores length-deductions]}
+ (compute-all-method-scores-detailed run-args run-config best-v)]
+ (progress-callback {:iteration current-iteration
+ :total-iterations iters
+ :best-formula (str (:expr best-v))
+ :best-formula-leaf-count (.leafCount ^IExpr (:expr best-v))
+ :best-score (or (:score best-v) min-score)
+ :best-scores scores
+ :best-raw-scores raw-scores
+ :length-deductions length-deductions
+ :percentiles {:p99 (or (:score best-p99-v) min-score)
+ :p95 (or (:score best-p95-v) min-score)
+ :p90 (or (:score best-p90-v) min-score)}
+ :scoring-method scoring-method
+ :simplicity-bias simplicity-bias}))
+ (catch Exception e
+ ;; Re-throw stop exceptions so the solver actually stops
+ (if (= :stopped (:type (ex-data e)))
+ (throw e)
+ (log/warn "Error in progress callback: " (.getMessage e))))))))
(reset! sim-stats* {}))
diff --git a/src/closyr/ops/common.clj b/src/closyr/ops/common.clj
index 85e484f2..7f7bf531 100644
--- a/src/closyr/ops/common.clj
+++ b/src/closyr/ops/common.clj
@@ -3,12 +3,11 @@
(:require
[clojure.core.async :as async :refer [go go-loop timeout !! ! chan put! take! alts!! alt!! close!]]
[closyr.util.log :as log]
- [closyr.util.prng :refer :all]
+ [closyr.util.prng :as prng :refer [rand rand-int rand-nth shuffle]]
[closyr.util.spec :as specs])
(:import
(java.util
- Date
- UUID)
+ Date)
(java.util.function
Function)
(org.matheclipse.core.eval
@@ -59,10 +58,18 @@
(.setQuietMode true)))
+(def ^:dynamic *eval-timeout-seconds*
+ "Timeout in seconds for expression evaluation. 0 means no timeout.
+ Default is 10 seconds to prevent infinite hangs."
+ 10)
+
+
(defn ^ExprEvaluator new-util
- "Create a new expr evaluator"
+ "Create a new expr evaluator with timeout to prevent infinite hangs.
+ The timeout prevents Symja from getting stuck on complex expressions
+ that would otherwise block threads indefinitely."
[]
- (ExprEvaluator. (new-eval-engine) true 0))
+ (ExprEvaluator. (new-eval-engine) true (int *eval-timeout-seconds*)))
(defn ^"[Lorg.matheclipse.core.interfaces.IExpr;" exprs->exprs-list
@@ -132,18 +139,34 @@
expr))
+(defn- hold-expr?
+ "Check if an expression is wrapped in Hold() which prevents numeric evaluation"
+ [^IExpr expr]
+ (and expr
+ (.isAST expr)
+ (= (.head expr) F/Hold)))
+
(defn ->phenotype
- "Create a GA phenotype from an expr and symbol and other args"
- [^ISymbol variable ^IAST expr ^ExprEvaluator util]
+ "Create a GA phenotype from an expr and symbol and other args.
+ IMPORTANT: Always creates a fresh ExprEvaluator for thread safety.
+ Symja's ExprEvaluator has internal mutable state (ArrayDeque stacks) that is not thread-safe.
+ When using pmap for parallel mutation/scoring, shared evaluators cause ArrayDeque corruption."
+ [^ISymbol variable ^IAST expr ^ExprEvaluator _util]
(try
- (let [^ExprEvaluator util (or util (new-util))]
- {:sym variable
- :util util
- :id (UUID/randomUUID)
- :expr (.eval util (valid-expr-or-default variable expr))})
+ ;; Always create fresh evaluator for thread safety - never reuse passed-in util
+ (let [^ExprEvaluator fresh-util (new-util)
+ ^IExpr result (.eval fresh-util (valid-expr-or-default variable expr))]
+ ;; Reject expressions wrapped in Hold() as they can't be numerically evaluated
+ (when-not (hold-expr? result)
+ {:sym variable
+ :util fresh-util
+ :id (prng/random-uuid)
+ :expr result}))
(catch Exception e
- (log/error "Err creating pheno from expr/x: "
- (str expr) " / " (str variable) " : " (or (.getMessage e) e)))))
+ ;; These are expected during evolution - not logged by default as they're noisy
+ nil
+ (log/debug "Eval error for expr: " (subs (str expr) 0 (min 60 (count (str expr)))) "..." (.getMessage e))
+ )))
(defn- probability-inversely-proportional-to-leaf-size
diff --git a/src/closyr/ops/eval.clj b/src/closyr/ops/eval.clj
index 540ddcfb..4900e3c5 100644
--- a/src/closyr/ops/eval.clj
+++ b/src/closyr/ops/eval.clj
@@ -38,28 +38,35 @@
(defn ^IExpr eval-phenotype-on-expr-args
- "Eval an expr at every point in the args"
+ "Eval an expr at every point in the args.
+ IMPORTANT: Always creates a fresh ExprEvaluator for thread safety.
+ Symja's ExprEvaluator has internal mutable state (stacks) that is not thread-safe.
+ When using pmap for parallel scoring, shared evaluators cause ArrayDeque corruption."
{:malli/schema [:=> [:cat #'specs/GAPhenotype #'specs/PrimitiveArrayOfIExpr] [:maybe #'specs/SymbolicExpr]]}
- [{^IAST expr :expr ^ISymbol x-sym :sym ^ExprEvaluator util :util p-id :id :as pheno}
+ [{^IAST expr :expr ^ISymbol x-sym :sym p-id :id :as pheno}
^"[Lorg.matheclipse.core.interfaces.IExpr;" expr-args]
(try
- (when-not util
- (log/warn "*** Warning: No util provided to evaluation engine! ***"))
(if (and expr
expr-args
(not (.isNIL expr)))
+ ;; Always create fresh evaluator for thread safety
(let [^IAST ast (F/ast expr-args (ops-common/expr->fn pheno))
- ^IExpr res (.eval (or util (ops-common/new-util)) ast)]
+ ^ExprEvaluator fresh-util (ops-common/new-util)
+ ^IExpr res (.eval fresh-util ast)]
res)
(log/warn "Warning: eval needs both expr and args, and expr cannot be NIL"))
- (catch Exception e (log/error "Warning: Error in eval: "
- (str expr) " : " (or (.getMessage e) e)))))
+ (catch Exception e
+ ;; Expected during evolution - log at debug level
+ (log/warn "Eval error: " (subs (str expr) 0 (min 50 (count (str expr))))
+ "..." (.getMessage e)))))
(defn- parseable-eval-result?
- [eval-p]
- (not (or (nil? eval-p)
- (= "Indeterminate" (str eval-p)))))
+ "Check if eval result is valid (not nil or Indeterminate).
+ Uses direct object comparison instead of expensive string conversion."
+ [^IExpr eval-p]
+ (and (some? eval-p)
+ (not (identical? eval-p F/Indeterminate))))
(defn- ^IExpr get-arg
@@ -75,7 +82,7 @@
(ops-common/expr->double res)
Double/POSITIVE_INFINITY))
(catch Exception e
- (log/error "Error in evaling function on input values: "
+ (log/warn "Error in evaling function on input values: "
(str eval-p) " : " (or (.getMessage e) e))
Double/POSITIVE_INFINITY)))
@@ -91,9 +98,10 @@
eval-p
arg0))))
(catch Exception e
- (log/error "Error in evaling function on const xs vector: "
+ (log/debug "Error in evaling function on const xs vector: "
(str eval-p) " : " (.getMessage e))
- (throw e))))
+ ;; Return infinity instead of throwing - let scoring handle bad phenotypes
+ Double/POSITIVE_INFINITY)))
(defn eval-vec-pheno
@@ -102,14 +110,19 @@
[p
{:keys [input-xs-list input-xs-count]
:as run-args}]
- (let [^IExpr new-expr (:expr p)
- ^IExpr eval-p (eval-phenotype-on-expr-args p input-xs-list)]
- (when (parseable-eval-result? eval-p)
- (mapv
- (if (= input-xs-count (dec (.size eval-p)))
- (partial result-args->doubles eval-p)
- (partial result-args->constant-input eval-p new-expr))
- (range input-xs-count)))))
+ (try
+ (let [^IExpr new-expr (:expr p)
+ ^IExpr eval-p (eval-phenotype-on-expr-args p input-xs-list)]
+ (when (parseable-eval-result? eval-p)
+ (mapv
+ (if (= input-xs-count (dec (.size eval-p)))
+ (partial result-args->doubles eval-p)
+ (partial result-args->constant-input eval-p new-expr))
+ (range input-xs-count))))
+ (catch Exception e
+ (log/warn "Error in eval-vec-pheno:" (.getMessage e))
+ ;; Return vector of infinities so scoring gives this phenotype a bad score
+ (vec (repeat input-xs-count Double/POSITIVE_INFINITY)))))
(defn- clamp-oversampled-ys
@@ -128,18 +141,16 @@
x-head-list :x-head-list
x-tail :x-tail
x-tail-list :x-tail-list}]
- (let [middle-section (eval-vec-pheno p run-args)
- max-y (reduce max middle-section)
- min-y (reduce min middle-section)]
- (concat
-
- (mapv #(clamp-oversampled-ys max-y min-y %)
- (eval-vec-pheno p (assoc run-args :input-xs-list x-head-list :input-xs-count (count x-head))))
-
- middle-section
-
- (mapv #(clamp-oversampled-ys max-y min-y %)
- (eval-vec-pheno p (assoc run-args :input-xs-list x-tail-list :input-xs-count (count x-tail)))))))
+ (let [middle-section (eval-vec-pheno p run-args)]
+ (when (seq middle-section)
+ (let [max-y (reduce max middle-section)
+ min-y (reduce min middle-section)]
+ (concat
+ (mapv #(clamp-oversampled-ys max-y min-y %)
+ (eval-vec-pheno p (assoc run-args :input-xs-list x-head-list :input-xs-count (count x-head))))
+ middle-section
+ (mapv #(clamp-oversampled-ys max-y min-y %)
+ (eval-vec-pheno p (assoc run-args :input-xs-list x-tail-list :input-xs-count (count x-tail)))))))))
#_(defn eval-vec-pheno-oversample-from-orig-xs
diff --git a/src/closyr/ops/initialize.clj b/src/closyr/ops/initialize.clj
index 731f0278..b6065477 100644
--- a/src/closyr/ops/initialize.clj
+++ b/src/closyr/ops/initialize.clj
@@ -4,6 +4,7 @@
[closyr.util.log :as log]
[closyr.util.spec :as specs])
(:import
+ (java.util.function Function)
(org.matheclipse.core.expression
AST
F)
@@ -224,42 +225,42 @@
:label "x+1/2"
:leaf-modifier-fn (fn ^IExpr [leaf-count {^IAST expr :expr ^ISymbol x-sym :sym :as pheno} ^IExpr ie]
(if (and (.isSymbol ie) (ops-common/should-modify-leaf leaf-count pheno))
- (F/Plus ie (F/C1D2))
+ (F/Plus ie F/C1D2)
ie))}
{:op :modify-leafs
:label "x-1/2"
:leaf-modifier-fn (fn ^IExpr [leaf-count {^IAST expr :expr ^ISymbol x-sym :sym :as pheno} ^IExpr ie]
(if (and (.isSymbol ie) (ops-common/should-modify-leaf leaf-count pheno))
- (F/Subtract ie (F/C1D2))
+ (F/Subtract ie F/C1D2)
ie))}
{:op :modify-leafs
:label "x/10"
:leaf-modifier-fn (fn ^IExpr [leaf-count {^IAST expr :expr ^ISymbol x-sym :sym :as pheno} ^IExpr ie]
(if (and (.isSymbol ie) (ops-common/should-modify-leaf leaf-count pheno))
- (F/Divide ie (F/C10))
+ (F/Divide ie F/C10)
ie))}
{:op :modify-leafs
:label "10*x"
:leaf-modifier-fn (fn ^IExpr [leaf-count {^IAST expr :expr ^ISymbol x-sym :sym :as pheno} ^IExpr ie]
(if (and (.isSymbol ie) (ops-common/should-modify-leaf leaf-count pheno))
- (F/Times ie (F/C10))
+ (F/Times ie F/C10)
ie))}
{:op :modify-leafs
:label "1/x"
:leaf-modifier-fn (fn ^IExpr [leaf-count {^IAST expr :expr ^ISymbol x-sym :sym :as pheno} ^IExpr ie]
(if (and (.isSymbol ie) (ops-common/should-modify-leaf leaf-count pheno))
- (F/Divide (F/C1) ie)
+ (F/Divide F/C1 ie)
ie))}
{:op :modify-leafs
:label "x/100"
:leaf-modifier-fn (fn ^IExpr [leaf-count {^IAST expr :expr ^ISymbol x-sym :sym :as pheno} ^IExpr ie]
(if (and (.isSymbol ie) (ops-common/should-modify-leaf leaf-count pheno))
- (F/Divide ie (F/C100))
+ (F/Divide ie F/C100)
ie))}
@@ -267,14 +268,14 @@
:label "100*x"
:leaf-modifier-fn (fn ^IExpr [leaf-count {^IAST expr :expr ^ISymbol x-sym :sym :as pheno} ^IExpr ie]
(if (and (.isSymbol ie) (ops-common/should-modify-leaf leaf-count pheno))
- (F/Times ie (F/C100))
+ (F/Times ie F/C100)
ie))}
{:op :modify-leafs
:label "-1*x"
:leaf-modifier-fn (fn ^IExpr [leaf-count {^IAST expr :expr ^ISymbol x-sym :sym :as pheno} ^IExpr ie]
(if (and (.isSymbol ie) (ops-common/should-modify-leaf leaf-count pheno))
- (F/Times ie (F/CN1))
+ (F/Times ie F/CN1)
ie))}
{:op :modify-leafs
@@ -337,14 +338,14 @@
:label "x^1/2"
:leaf-modifier-fn (fn ^IExpr [leaf-count {^IAST expr :expr ^ISymbol x-sym :sym :as pheno} ^IExpr ie]
(if (and (.isSymbol ie) (ops-common/should-modify-leaf leaf-count pheno))
- (F/Power ie (F/C1D2))
+ (F/Power ie F/C1D2)
ie))}
{:op :modify-leafs
:label "x^2"
:leaf-modifier-fn (fn ^IExpr [leaf-count {^IAST expr :expr ^ISymbol x-sym :sym :as pheno} ^IExpr ie]
(if (and (.isSymbol ie) (ops-common/should-modify-leaf leaf-count pheno))
- (F/Power ie (F/C2))
+ (F/Power ie F/C2)
ie))}
{:op :modify-leafs
@@ -414,8 +415,7 @@
:label "c+1/10"
:leaf-modifier-fn (fn ^IExpr [leaf-count {^IAST expr :expr ^ISymbol x-sym :sym :as pheno} ^IExpr ie]
(if (and (.isNumber ie) (ops-common/should-modify-leaf leaf-count pheno))
- (do
- (F/Plus ie (F/Divide 1 F/C10)))
+ (F/Plus ie (F/Divide 1 F/C10))
ie))}
{:op :modify-leafs
@@ -436,8 +436,7 @@
:label "c+1/100"
:leaf-modifier-fn (fn ^IExpr [leaf-count {^IAST expr :expr ^ISymbol x-sym :sym :as pheno} ^IExpr ie]
(if (and (.isNumber ie) (ops-common/should-modify-leaf leaf-count pheno))
- (do
- (F/Plus ie (F/Divide 1 F/C100)))
+ (F/Plus ie (F/Divide 1 F/C100))
ie))}
{:op :modify-leafs
@@ -665,4 +664,135 @@
ie))}])
+(defn mutation-labels
+ "Get all available mutation labels"
+ []
+ (mapv :label (initial-mutations)))
+
+
+(defn filter-mutations
+ "Filter mutations based on whitelist and/or blacklist.
+ - whitelist: if provided, only include mutations with labels in this set
+ - blacklist: if provided, exclude mutations with labels in this set
+ Whitelist is applied first, then blacklist."
+ [{:keys [whitelist blacklist]}]
+ (let [all-muts (initial-mutations)
+ filtered (if (seq whitelist)
+ (let [whitelist-set (set whitelist)]
+ (filterv #(whitelist-set (:label %)) all-muts))
+ all-muts)
+ filtered (if (seq blacklist)
+ (let [blacklist-set (set blacklist)]
+ (filterv #(not (blacklist-set (:label %))) filtered))
+ filtered)]
+ (if (empty? filtered)
+ (throw (IllegalArgumentException.
+ "No mutations remaining after filtering. Check your whitelist/blacklist."))
+ filtered)))
+
+
+;;; ============================================================================
+;;; Formula Parsing for Seeding
+;;; ============================================================================
+
+
+(defn- make-tree-replacer
+ "Create a Java Function that recursively replaces any symbol named 'x' with target-sym.
+ This is needed because the Symja parser creates its own Symbol instances that are
+ NOT equal to F/x or (F/Dummy \"x\") even though they have the same name."
+ [^ISymbol target-sym]
+ (ops-common/as-function
+ (fn tree-replace [^IExpr ie]
+ (cond
+ ;; Replace any symbol named "x" with our target symbol
+ (and (.isSymbol ie) (= "x" (str ie)))
+ target-sym
+
+ ;; Recursively process IAST nodes
+ (instance? IAST ie)
+ (.map ^IAST ie (make-tree-replacer target-sym))
+
+ ;; Return everything else unchanged
+ :else ie))))
+
+
+(defn- replace-x-symbols
+ "Replace all symbols named 'x' in the expression with sym-x.
+ Uses recursive tree walk to handle nested expressions."
+ ^IExpr [^IExpr expr]
+ (let [^Function replacer (make-tree-replacer ops-common/sym-x)]
+ (.replaceAll expr replacer)))
+
+
+(defn- valid-parsed-expr?
+ "Check if a parsed expression is valid for use in evolution.
+ Rejects expressions containing problematic patterns that cause crashes."
+ [^IExpr expr]
+ (let [expr-str (str expr)]
+ (and expr
+ (not (.isNIL expr))
+ ;; Reject expressions that won't evaluate properly
+ (not (.contains expr-str "Hold["))
+ (not (.contains expr-str "Hold("))
+ (not (.contains expr-str "Function["))
+ (not (.contains expr-str "Function("))
+ (not (.contains expr-str "{x}"))
+ (not (.contains expr-str "<<"))
+ ;; Reject expressions that are just symbols or overly simple
+ (not (.isBuiltInSymbol expr)))))
+
+
+(defn parse-formula->phenotype
+ "Parse a formula string into a phenotype suitable for GA evolution.
+
+ Returns nil if parsing fails or produces an invalid expression.
+ Replaces parser's 'x' symbol with ops-common/sym-x for correct evaluation.
+
+ Example: (parse-formula->phenotype \"Sin(x) + x^2\")
+ => {:sym sym-x :expr :util :id }"
+ [^String formula-str]
+ (try
+ (let [util (ops-common/new-util)
+ ;; Parse the string to an expression
+ ^IExpr parsed (.eval util formula-str)]
+ (when (valid-parsed-expr? parsed)
+ ;; Replace parser's x symbols with our sym-x
+ (let [^IExpr fixed-expr (replace-x-symbols parsed)
+ ;; Evaluate to simplify/normalize
+ ^IExpr evaled (.eval util fixed-expr)]
+ (when (valid-parsed-expr? evaled)
+ ;; Create phenotype with our sym-x
+ (ops-common/->phenotype ops-common/sym-x evaled util)))))
+ (catch Exception e
+ (log/error "Error parsing formula:" formula-str "-" (.getMessage e))
+ nil)))
+
+
+(defn seeded-phenotypes
+ "Create initial phenotypes by parsing formula strings.
+
+ Parameters:
+ - formulas: vector of formula strings to seed from
+ - fresh-percent: percentage of population to be fresh (default 0.2 = 20%)
+ - total-count: total population size
+
+ Returns a vector of phenotypes with (1 - fresh-percent) seeded from formulas
+ and fresh-percent from initial-phenotypes."
+ [formulas fresh-percent total-count]
+ (let [seed-count (int (* total-count (- 1.0 fresh-percent)))
+ fresh-count (- total-count seed-count)
+ ;; Parse formulas, filtering out failures
+ parsed (vec (keep parse-formula->phenotype formulas))
+ _ (log/info "Parsed" (count parsed) "of" (count formulas) "seed formulas successfully")
+ ;; If not enough parsed, repeat them to fill seed-count
+ seeded (if (empty? parsed)
+ []
+ (take seed-count (cycle parsed)))
+ ;; Add fresh phenotypes only if fresh-count > 0 (malli rejects 0)
+ fresh (if (pos? fresh-count)
+ (initial-phenotypes fresh-count)
+ [])]
+ (into (vec seeded) fresh)))
+
+
(specs/instrument-all!)
diff --git a/src/closyr/ops/modify.clj b/src/closyr/ops/modify.clj
index 35fd98ff..477a6939 100644
--- a/src/closyr/ops/modify.clj
+++ b/src/closyr/ops/modify.clj
@@ -3,9 +3,10 @@
(:require
[clojure.core.async :as async :refer [go go-loop timeout !! ! chan put! take! alts!! alt!! close!]]
[clojure.string :as str]
+ [closyr.adaptive :as adaptive]
[closyr.ops.common :as ops-common]
[closyr.util.log :as log]
- [closyr.util.prng :refer :all]
+ [closyr.util.prng :refer [rand rand-int rand-nth shuffle]]
[closyr.util.spec :as specs])
(:import
(java.util.function
@@ -177,15 +178,17 @@
(if discount-mod?
;; keep last op:
- (merge p (ops-common/->phenotype x-sym e1 (:util p-discard)))
+ (if-let [refreshed (ops-common/->phenotype x-sym e1 (:util p-discard))]
+ (merge p refreshed)
+ p)
;; record new last op:
- (-> x-sym
- (ops-common/->phenotype new-expr (:util p-discard))
- (with-recent-mod-metadata {:label (name crossover-flavor)
- :op :modify-crossover}))))
+ (some-> x-sym
+ (ops-common/->phenotype new-expr (:util p-discard))
+ (with-recent-mod-metadata {:label (name crossover-flavor)
+ :op :modify-crossover}))))
(catch Exception e
- (log/error "Error in ops/crossover: " (.getMessage e))
+ (log/debug "Error in ops/crossover: " (.getMessage e))
nil)))
@@ -231,7 +234,7 @@
(catch Exception e
(if (= "Infinite expression 1/0 encountered." (.getMessage e))
(divided-by-zero)
- (log/warn
+ (log/debug
"Warning, mutation failed: " (:label mod-to-apply)
" on: " (type expr-prior) " / " (str expr-prior)
" due to: " (or (.getMessage e) e)))
@@ -275,17 +278,26 @@
(concat (repeat 3 13))
(concat (repeat 2 14))
(concat (repeat 1 15))
- ;; (concat (repeat 5 16))
- ;; (concat (repeat 4 17))
- ;; (concat (repeat 3 18))
- ;; (concat (repeat 2 19))
- ;; (concat (repeat 1 20))
- ;; (concat (repeat 3 21))
- ;; (concat (repeat 2 22))
- ;; (concat (repeat 1 23))
- ;; (concat (repeat 1 24))
- ;; (concat (repeat 1 25))
vec))
+(def ^:private mutations-sampler-max
+ "Maximum value in mutations-sampler for clamping boosted values"
+ 15)
+
+
+(defn sample-mutation-count
+ "Sample mutation count with optional adaptive boost.
+ When adaptive mode is enabled and we're stagnating, the boost multiplier
+ increases the sampled count to explore more aggressively."
+ ([]
+ (sample-mutation-count (adaptive/get-mutation-count-boost)))
+ ([boost]
+ (let [base-count (rand-nth mutations-sampler)]
+ (if (= boost 1.0)
+ base-count
+ (let [boosted (int (Math/ceil (* base-count boost)))]
+ (min mutations-sampler-max boosted))))))
+
+
(specs/instrument-all!)
diff --git a/src/closyr/symbolic_regression.clj b/src/closyr/symbolic_regression.clj
index b4a22a12..ffc97dce 100644
--- a/src/closyr/symbolic_regression.clj
+++ b/src/closyr/symbolic_regression.clj
@@ -1,17 +1,21 @@
(ns closyr.symbolic-regression
(:require
[clojure.core.async :as async :refer [go go-loop timeout !! ! chan put! take! alts!! alts! close!]]
+ [closyr.adaptive :as adaptive]
[closyr.ga :as ga]
[closyr.ops :as ops]
[closyr.ops.common :as ops-common]
[closyr.ops.initialize :as ops-init]
[closyr.ui.gui :as gui]
[closyr.util.log :as log]
+ [closyr.util.prng :as prng]
[closyr.util.spec :as specs]
[flames.core :as flames]
[malli.core :as m]
[seesaw.core :as ss])
(:import
+ (java.awt
+ Color)
(java.util
Date
List)
@@ -131,17 +135,18 @@
(defn- check-if-done
- [i iters status-label ctl-start-stop-btn]
+ [i iters ^JLabel status-label ctl-start-stop-btn]
(when (= iters i)
(let [^JButton reset-btn @gui/ctl-reset-btn*]
(ss/set-text* ctl-start-stop-btn gui/ctl:start)
(.setEnabled reset-btn false)
- (ss/set-text* status-label (str "Done")))))
+ (ss/set-text* status-label (str "Done"))
+ (.setForeground status-label (Color. 180 180 180)))))
(defn- check-new-best-fn
[best-f-str ^JTextField best-fn-selectable-text]
- (let [fn-str (str "y = " (ops/format-fn-str best-f-str))]
+ (let [fn-str (str "" (ops/format-fn-str best-f-str))]
(when (not= fn-str (.getText best-fn-selectable-text))
(log/info "New Best Function: " fn-str)
(ss/set-text* best-fn-selectable-text fn-str))))
@@ -241,7 +246,7 @@
(.repaint scores-chart-panel)))
-(defn chart-update-loop
+(defn- chart-update-loop
"In the GUI thread, loops over data sent from the experiement to be rendered onto the GUI. Parks waiting on new data,
and ends the loop when a command in the close chan is sent"
[sim->gui-chan
@@ -268,7 +273,7 @@
(defn- setup-gui
[]
- (let [sim->gui-chan *sim->gui-chan*
+ (let [sim->gui-chan *sim->gui-chan*
sim-stop-start-chan *sim-stop-start-chan*
{:keys [input-xs-vec input-ys-vec]} @sim-input-args*]
(gui/create-and-show-gui
@@ -299,30 +304,44 @@
:sim-stop-start-chan sim-stop-start-chan}))
-(defn update-plot-input-data
+(defn- update-plot-input-data
"Get new data from GUI and generate necessary solver inputs"
{:malli/schema [:=> [:cat #'specs/SolverGUIMessage] #'specs/SolverGUIInputArgs]}
- [{new-state :new-state
- input-data-x :input-data-x
- input-data-y :input-data-y
- input-iters :input-iters
- input-phenos-count :input-phenos-count
- max-leafs :max-leafs}]
+ [{new-state :new-state
+ input-data-x :input-data-x
+ input-data-y :input-data-y
+ input-iters :input-iters
+ input-phenos-count :input-phenos-count
+ random-seed :random-seed
+ max-leafs :max-leafs
+ mutations-blacklist :mutations-blacklist
+ log-steps :log-steps
+ adaptive-mode :adaptive-mode
+ quiet-logs :quiet-logs
+ use-eval-cache :use-eval-cache
+ scoring-method :scoring-method}]
(let [input-xs-exprs (ops-common/doubles->exprs input-data-x)
input-ys-exprs (ops-common/doubles->exprs input-data-y)
- input-ys-vec (ops-common/exprs->doubles input-ys-exprs)
- input-xs-vec (ops-common/exprs->doubles input-xs-exprs)]
-
- (reset! sim-input-args* {:input-xs-exprs input-xs-exprs
- :input-xs-vec input-xs-vec
- :input-ys-vec input-ys-vec
- :input-iters input-iters
- :input-phenos-count input-phenos-count
- :max-leafs max-leafs})))
-
-
-(defn restart-with-new-inputs
+ input-ys-vec (ops-common/exprs->doubles input-ys-exprs)
+ input-xs-vec (ops-common/exprs->doubles input-xs-exprs)]
+
+ (reset! sim-input-args* {:input-xs-exprs input-xs-exprs
+ :input-xs-vec input-xs-vec
+ :input-ys-vec input-ys-vec
+ :input-iters input-iters
+ :input-phenos-count input-phenos-count
+ :mutations-blacklist mutations-blacklist
+ :random-seed random-seed
+ :max-leafs max-leafs
+ :log-steps log-steps
+ :adaptive-mode adaptive-mode
+ :quiet-logs quiet-logs
+ :use-eval-cache use-eval-cache
+ :scoring-method scoring-method})))
+
+
+(defn- restart-with-new-inputs
"Get new inputs and restart solver"
{:malli/schema [:=> [:cat #'specs/SolverGUIMessage] keyword?]}
[msg]
@@ -352,17 +371,25 @@
nil))))))))
-(defn ->run-args
+(defn- ->run-args
"Generate one-time computed args for solver"
{:malli/schema [:=> [:cat #'specs/SolverInputArgs] #'specs/SolverRunArgs]}
- [{input-xs-exprs :input-xs-exprs
- input-xs-vec :input-xs-vec
- input-ys-vec :input-ys-vec
- input-iters :input-iters
- iters :iters
- input-phenos-count :input-phenos-count
- max-leafs :max-leafs
- initial-phenos :initial-phenos}]
+ [{input-xs-exprs :input-xs-exprs
+ input-xs-vec :input-xs-vec
+ input-ys-vec :input-ys-vec
+ input-iters :input-iters
+ iters :iters
+ input-phenos-count :input-phenos-count
+ random-seed :random-seed
+ max-leafs :max-leafs
+ initial-phenos :initial-phenos
+ mutations-blacklist :mutations-blacklist
+ log-steps :log-steps
+ adaptive-mode :adaptive-mode
+ quiet-logs :quiet-logs
+ use-eval-cache :use-eval-cache
+ scoring-method :scoring-method
+ simplicity-bias :simplicity-bias}]
(when-not (and input-xs-exprs
input-xs-vec
@@ -377,10 +404,19 @@
:input-xs-count (count input-xs-exprs)
:input-xs-vec input-xs-vec
:input-ys-vec input-ys-vec
+ :input-ys-arr (double-array input-ys-vec)
:input-iters (or input-iters iters)
:initial-phenos initial-phenos
:input-phenos-count input-phenos-count
- :max-leafs max-leafs})
+ :random-seed random-seed
+ :max-leafs max-leafs
+ :mutations-blacklist mutations-blacklist
+ :log-steps log-steps
+ :adaptive-mode adaptive-mode
+ :quiet-logs quiet-logs
+ :use-eval-cache use-eval-cache
+ :scoring-method scoring-method
+ :simplicity-bias simplicity-bias})
(defn- wait-and-get-gui-args
@@ -393,7 +429,7 @@
(defn- start-gui-and-get-input-data
"Initialize and show GUI, then park and wait on user input to start"
- [{:keys [iters initial-phenos initial-muts input-xs-exprs input-ys-exprs] :as run-config}]
+ [{:keys [iters initial-phenos initial-muts random-seed input-xs-exprs input-ys-exprs] :as run-config}]
;; these are the data shown in the plots before the experiment is started:
(reset! sim-input-args* {:input-xs-vec (ops-common/exprs->doubles input-xs-exprs)
@@ -405,11 +441,15 @@
(merge gui-comms (wait-and-get-gui-args sim-stop-start-chan))))
+(def ^:private finished-eps
+ {:mae-max -1e-3
+ :log-cosh -1e-3
+ :r-squared -1e-4})
(defn- next-iters
"Determine how many more GA iterations are left based on score, and stop if near perfect solution."
[i scores]
- (if (some #(> % -1e-3) scores)
+ (if (some #(> % (get finished-eps ops/*scoring-method* -1e-3)) scores)
(near-exact-solution i scores)
(dec i)))
@@ -419,20 +459,29 @@
(log/info "-- Done! Next state: " next-step
" took" (/ (ops-common/start-date->diff-ms start) 1000.0)
" seconds for iters: " iters-done
- " --"))
+ " --")
+ (when ops/*use-eval-cache*
+ (let [{:keys [size hits misses]} (ops/eval-cache-stats)]
+ (log/info "-- Eval cache stats: size=" size " hits=" hits " misses=" misses
+ " hit-rate=" (if (pos? (+ hits misses))
+ (format "%.1f%%" (* 100.0 (/ hits (+ hits misses))))
+ "N/A")
+ " --"))))
(defn- print-and-save-start-time
- [iters initial-phenos]
+ [iters initial-phenos run-config]
(let [start (Date.)]
(log/info "-- Start " start
"iters: " iters
" pop size: " (count initial-phenos)
+ " random seed: " (:random-seed run-config)
+ " deterministic mode: " ga/*deterministic-mode*
" --")
(reset! ops/test-timer* start)))
-(defprotocol ISolverStateController
+(defprotocol IIterativeGASolver
"Interface which allows creation and iteration of the symbolic regression GA solver"
@@ -458,45 +507,45 @@
"Report timing/perf results"))
-(defrecord SolverStateController
+(defrecord IterativeGASolver
[;; the chans? also these names are really really ambiguous and overloaded:
run-config
run-args]
- ISolverStateController
+ IIterativeGASolver
(init
[this]
(specs/validate! "SolverRunConfig" #'specs/SolverRunConfig run-config)
(specs/validate! "SolverRunArgs" #'specs/SolverRunArgs run-args)
(let [{:keys [iters initial-phenos initial-muts use-gui?]} run-config
- start (print-and-save-start-time iters initial-phenos)
+ start (print-and-save-start-time iters initial-phenos run-config)
init-pop (ga/initialize
initial-phenos
(partial ops/score-fn run-args run-config)
(partial ops/mutation-fn run-config initial-muts)
(partial ops/crossover-fn run-config initial-muts))]
- (log/info "Running with logging every n steps: " (:log-steps run-config))
+ (log/info "Running with progress update / logging every n steps: " (:log-steps run-config))
(assoc this
- :ga-result init-pop
- :iters-to-go iters
- :start-ms start)))
+ :ga-result init-pop
+ :iters-to-go iters
+ :start-ms start)))
(solver-step
[this]
(let [{:keys [iters log-steps]} run-config
- population (:ga-result this)
+ population (:ga-result this)
iters-to-go (:iters-to-go this)]
(binding [ops/*log-steps* log-steps]
(if (zero? iters-to-go)
(assoc this
- :status :done
- :result {:iters-done (- iters iters-to-go)
- :final-population population
- :next-step :wait})
+ :status :done
+ :result {:iters-done (- iters iters-to-go)
+ :final-population population
+ :next-step :wait})
(let [{scores :pop-scores :as ga-result} (ga/evolve population)]
(specs/validate! "GAPopulation" #'specs/GAPopulationPhenotypes (:pop ga-result))
(ops/report-iteration iters-to-go iters ga-result run-args run-config)
@@ -506,8 +555,8 @@
(next-state
[this]
(let [{:keys [iters initial-phenos initial-muts use-gui?]} run-config
- iters-to-go (:iters-to-go this)
- population (:ga-result this)
+ iters-to-go (:iters-to-go this)
+ population (:ga-result this)
should-return-state (and use-gui? (check-gui-command-and-maybe-park run-args))]
(if (and use-gui? should-return-state)
(case should-return-state
@@ -541,36 +590,71 @@
return-value))
-(defn run-ga-iterations-using-record
+(defn run-solver-ga-iterations
"Run GA evolution iterations on initial population"
{:malli/schema [:=> [:cat #'specs/SolverRunConfig #'specs/SolverRunArgs] #'specs/SolverRunResults]}
[run-config run-args]
- (loop [solver-state (init (map->SolverStateController {:run-config run-config :run-args run-args}))]
- (let [[recur? next-solver-state] (run-iteration solver-state)]
- (if recur?
- (recur next-solver-state)
- next-solver-state))))
+ (binding [ga/*deterministic-mode* (some? (:random-seed run-config))]
+ ;; Set the random seed if provided
+ (when (:random-seed run-config)
+ (log/info "run-solver-ga-iterations: Deterministic mode enabled with seed:" (:random-seed run-config)
+ "- CPU parallelism disabled for reproducibility")
+ (prng/set-random-seed! (:random-seed run-config)))
+ (loop [solver-state (init (map->IterativeGASolver {:run-config run-config :run-args run-args}))]
+ (let [[recur? next-solver-state] (run-iteration solver-state)]
+ (if recur?
+ (recur next-solver-state)
+ next-solver-state)))))
(defn- merge-cli-and-gui-args
- [{cli-max-leafs :max-leafs :keys [iters initial-phenos initial-muts use-gui?] :as run-config}
- {:keys [input-iters input-phenos-count max-leafs input-xs-list input-xs-count input-ys-vec
- sim-stop-start-chan sim->gui-chan]
+ [{cli-max-leafs :max-leafs :keys [iters initial-phenos initial-muts use-gui? scoring-method] :as run-config}
+ {:keys [input-iters input-phenos-count random-seed max-leafs input-xs-list input-xs-count input-ys-vec
+ sim-stop-start-chan sim->gui-chan mutations-blacklist log-steps adaptive-mode quiet-logs use-eval-cache]
+ gui-scoring-method :scoring-method
:as run-args}]
- (let [max-leafs (or max-leafs cli-max-leafs)
- iters (or input-iters iters)
+ (let [max-leafs (or max-leafs cli-max-leafs)
+ iters (or input-iters iters)
initial-phenos (if input-phenos-count
(ops-init/initial-phenotypes input-phenos-count)
initial-phenos)
-
- run-config (assoc run-config
- :initial-phenos initial-phenos
- :iters iters
- :max-leafs (or max-leafs ops/default-max-leafs))
-
- run-config (assoc run-config
- :log-steps (config->log-steps run-config run-args))]
+ ;; Apply mutations blacklist from GUI if provided
+ initial-muts (if (seq mutations-blacklist)
+ (ops-init/filter-mutations {:blacklist mutations-blacklist})
+ initial-muts)
+ ;; GUI scoring method takes precedence over CLI
+ effective-scoring-method (or gui-scoring-method scoring-method :mae-max)
+
+ run-config (assoc run-config
+ :initial-phenos initial-phenos
+ :initial-muts initial-muts
+ :random-seed random-seed
+ :iters iters
+ :max-leafs (or max-leafs ops/default-max-leafs)
+ :adaptive-mode adaptive-mode
+ :quiet-logs quiet-logs
+ :use-eval-cache use-eval-cache
+ :scoring-method effective-scoring-method)
+
+ _ (when (seq mutations-blacklist)
+ (log/info "GUI: using" (count initial-muts) "mutations"
+ "(" (count mutations-blacklist) "excluded)"))
+
+ _ (when adaptive-mode
+ (log/info "GUI: adaptive mutations enabled"))
+
+ _ (when use-eval-cache
+ (log/info "GUI: evaluation cache enabled"))
+
+ _ (when (not= effective-scoring-method :mae-max)
+ (log/info "GUI: scoring method:" effective-scoring-method))
+
+ ;; Use GUI-provided log-steps if set, otherwise auto-calculate
+ computed-log-steps (or log-steps (config->log-steps run-config run-args))
+
+ run-config (assoc run-config
+ :log-steps computed-log-steps)]
run-config))
@@ -581,8 +665,7 @@
sim-stop-start-chan sim->gui-chan]
:as run-args}]
(let [run-config (merge-cli-and-gui-args run-config run-args)
- {:keys [next-step] :as completed-ga-data} (run-ga-iterations-using-record run-config run-args)]
-
+ {:keys [next-step] :as completed-ga-data} (run-solver-ga-iterations run-config run-args)]
(case next-step
:stop
@@ -592,13 +675,13 @@
(if use-gui?
(do (log/info "-- Waiting for GUI input to start again --")
(if-let [new-gui-args (wait-and-get-gui-args sim-stop-start-chan)]
- (recur run-config (merge run-args new-gui-args))
+ (run-from-inputs run-config (merge run-args new-gui-args))
completed-ga-data))
completed-ga-data)
:restart
(do (log/info "-- Restarting... --")
- (recur run-config (merge run-args (->run-args @sim-input-args*)))))))
+ (run-from-inputs run-config (merge run-args (->run-args @sim-input-args*)))))))
(defn- in-flames
@@ -621,71 +704,71 @@
run-config)))
-(defprotocol ISymbolicRegressionSolver
-
- "A top-level interface to start the solver using CLI or GUI args"
-
- (solve
- [this]
- "Run the solver on either CLI of GUI args. When using GUI, we block on getting a signal from the
- GUI which indicates the user wants to start (and later stop/restart) the solver. The GUI
- would also provide all the parameters and inputs to the solver, like iterations count and
- the objective data. When running from the CLI, we use the provided inputs or some example data
- and defaults."))
-
-
-(defrecord SymbolicRegressionSolver
- [iters initial-phenos initial-muts input-xs-exprs input-ys-exprs use-gui? use-flamechart max-leafs]
-
- ISymbolicRegressionSolver
-
- (solve
- [this]
- (let [symbolic-regression-solver-fn (fn []
- (run-from-inputs
- this
- (if use-gui?
- (start-gui-and-get-input-data this)
- (get-input-data this))))]
- (if use-gui?
- (log/info "-- Running from GUI --")
- (log/info "-- Running from CLI."
- "iters: " iters
- "pop: " (count initial-phenos)
- "muts: " (count initial-muts) " --"))
-
+(defn run-find-formula
+ "Run a GA evolution solver to search for function of best fit for input data.
+ This is the main programmatic entry point for the symbolic regression solver.
+
+ Options:
+ :use-eval-cache - when true, cache evaluation results by expression string (default: false)
+ :scoring-method - scoring method (:mae-max, :log-cosh, or :r-squared)
+ :simplicity-bias - simplicity bias level (:none, :tiebreaker, :light, or :strong)"
+ [{:keys [iters initial-phenos initial-muts input-xs-exprs input-ys-exprs use-gui? use-flamechart random-seed adaptive-mode use-eval-cache scoring-method simplicity-bias] :as run-config}]
+ ;; Reset adaptive state and eval cache for new run
+ (adaptive/reset-adaptive-state!)
+ (ops/clear-eval-cache!)
+ (binding [ga/*deterministic-mode* (some? random-seed)
+ ga/*adaptive-mode* (if (and (some? adaptive-mode) (not (some? random-seed))) adaptive-mode false)
+ ops/*use-eval-cache* (boolean use-eval-cache)
+ ops/*scoring-method* (or scoring-method :mae-max)
+ ops/*simplicity-bias* (or simplicity-bias :tiebreaker)]
+ ;; Set the random seed if provided
+ (when random-seed
+ (log/info "---- run-find-formula: Deterministic mode enabled with seed:" random-seed
+ "- CPU parallelism disabled for reproducibility")
+ (prng/set-random-seed! random-seed))
+
+ (log/debug "---- ---- Modes:"
+ " deterministic-mode: " ga/*deterministic-mode*
+ " adaptive-mode: " ga/*adaptive-mode*
+ " use-eval-cache: " ops/*use-eval-cache*
+ " scoring-method: " ops/*scoring-method*
+ " simplicity-bias: " ops/*simplicity-bias*)
+
+ ;(when ga/*deterministic-mode*
+ ; (log/warn "---- Running Deterministic Mode ----"))
+ ;
+ ;(when ga/*adaptive-mode*
+ ; (log/warn "---- Running Adaptive Mode ----"))
+ ;
+ ;(when ops/*use-eval-cache*
+ ; (log/warn "---- Running With Eval Cache ----"))
+ ;
+ ;(when ops/*scoring-method*
+ ; (log/warn "---- Running With Scoring Method:" ops/*scoring-method* "----"))
+
+ (if use-gui?
+ (log/info "-- Running from GUI --")
+ (log/info "-- Running from CLI/Web."
+ "iters:" iters
+ "pop:" (count initial-phenos)
+ "muts:" (count initial-muts) "--"))
+
+ (let [solver-fn (fn []
+ (run-from-inputs
+ run-config
+ (if use-gui?
+ (start-gui-and-get-input-data run-config)
+ (get-input-data run-config))))]
(if use-flamechart
- ;; with flame graph analysis:
- (in-flames symbolic-regression-solver-fn)
- ;; plain experiment:
- (symbolic-regression-solver-fn)))))
-
-
-(defn run-solver
- "Run a GA evolution solver to search for function of best fit for input data. The
- word experiment is used loosely here, it's more of a time-evolving best-fit method instance."
- [{:keys [iters initial-phenos initial-muts input-xs-exprs input-ys-exprs use-gui?] :as run-config}]
- (solve (map->SymbolicRegressionSolver run-config)))
-
-
-(defn run-app-without-gui
- "Run app without GUI and with fake placeholder input data"
- [xs ys]
- (run-solver
- {:initial-phenos (ops-init/initial-phenotypes 100)
- :initial-muts (ops-init/initial-mutations)
- :iters 20
- :use-gui? false
- :use-flamechart false
- :input-xs-exprs (ops-common/doubles->exprs xs)
- :input-ys-exprs (ops-common/doubles->exprs ys)}))
+ (in-flames solver-fn)
+ (solver-fn)))))
(defn- run-app-with-gui
([]
(run-app-with-gui {:use-flamechart false}))
([{:keys [use-flamechart]}]
- (run-solver
+ (run-find-formula
{:initial-phenos (ops-init/initial-phenotypes 50)
:initial-muts (ops-init/initial-mutations)
:iters 100
@@ -709,21 +792,34 @@
(defn run-app-from-cli-args
"Run app from CLI args"
{:malli/schema [:=> [:cat #'specs/CLIArgs] #'specs/SolverRunResults]}
- [{:keys [iterations population headless xs ys use-flamechart max-leafs] :as cli-opts}]
+ [{:keys [iterations population headless xs ys use-flamechart max-leafs seed
+ mutations-whitelist mutations-blacklist adaptive-mode quiet-logs use-eval-cache
+ scoring-method] :as cli-opts}]
(log/info "CLI: run from options: " cli-opts)
- (let [run-config {:initial-phenos (ops-init/initial-phenotypes population)
- :initial-muts (ops-init/initial-mutations)
+ ;; Run with deterministic mode if seed is set (disables parallel execution)
+ (let [initial-muts (if (or mutations-whitelist mutations-blacklist)
+ (ops-init/filter-mutations {:whitelist mutations-whitelist
+ :blacklist mutations-blacklist})
+ (ops-init/initial-mutations))
+ _ (log/info "CLI: using" (count initial-muts) "mutations")
+ run-config {:initial-phenos (ops-init/initial-phenotypes population)
+ :initial-muts initial-muts
:iters iterations
:use-gui? (not headless)
+ :random-seed seed
:max-leafs max-leafs
:use-flamechart use-flamechart
+ :adaptive-mode adaptive-mode
+ :quiet-logs quiet-logs
+ :use-eval-cache use-eval-cache
+ :scoring-method scoring-method
:input-xs-exprs (if xs
(ops-common/doubles->exprs xs)
example-input-xs-exprs)
:input-ys-exprs (if ys
(ops-common/doubles->exprs ys)
example-input-ys-exprs)}
- result (run-solver run-config)]
+ result (run-find-formula run-config)]
(log/info "CLI: Done!")
(exit cli-opts)
result))
@@ -736,6 +832,13 @@
(comment (println "FN SCHEMAS: " (m/function-schemas)))
(comment (macroexpand-1 `(log/info "Hello")))
(comment (log/info "Hello"))
-(comment (run-app-without-gui))
+(comment (run-find-formula
+ {:initial-phenos (ops-init/initial-phenotypes 100)
+ :initial-muts (ops-init/initial-mutations)
+ :iters 20
+ :use-gui? false
+ :use-flamechart false
+ :input-xs-exprs (ops-common/doubles->exprs [1 2 3])
+ :input-ys-exprs (ops-common/doubles->exprs [6 12 99])}))
(comment (run-app-with-gui {:use-flamechart true}))
(comment (run-app-with-gui))
diff --git a/src/closyr/ui/components.clj b/src/closyr/ui/components.clj
new file mode 100644
index 00000000..4e6142e2
--- /dev/null
+++ b/src/closyr/ui/components.clj
@@ -0,0 +1,96 @@
+(ns closyr.ui.components
+ "Reusable UI component utilities"
+ (:require
+ [seesaw.behave :as sb]
+ [seesaw.core :as ss]
+ [seesaw.graphics :as sg])
+ (:import
+ (java.awt
+ BorderLayout
+ Color
+ GridLayout
+ Point)
+ (java.awt.event
+ MouseEvent)
+ (javax.swing
+ BorderFactory
+ JPanel)
+ (javax.swing.border
+ Border)))
+
+
+(set! *warn-on-reflection* true)
+
+
+(defn ^JPanel panel-grid
+ "Create a JPanel with GridLayout and optional border"
+ [{:keys [rows cols ^Border border]}]
+ (let [panel (doto (JPanel. (BorderLayout.))
+ (.setLayout (GridLayout. rows cols)))]
+ (cond-> panel
+ (not (nil? border)) (.setBorder border))
+ panel))
+
+
+(defn radio-controls-border
+ "Create a titled border for radio button groups"
+ [title]
+ (BorderFactory/createTitledBorder (BorderFactory/createLineBorder (Color. 80 80 80) 1) title))
+
+
+(def ^:private on-drag-finish-callback* (atom nil))
+
+(defn set-on-drag-finish-callback!
+ "Set a callback to be called when any movable widget finishes being dragged"
+ [callback-fn]
+ (reset! on-drag-finish-callback* callback-fn))
+
+(defn movable
+ "Make a widget draggable with mouse. Options: {:disable-x? true} to lock horizontal movement."
+ ([w] (movable w {:disable-x? false}))
+ ([w {disable-x? :disable-x?}]
+ (let [^Point start-point (Point.)]
+ (sb/when-mouse-dragged
+ w
+ ;; When the mouse is pressed, move the widget to the front of the z order
+ :start (fn [^MouseEvent e]
+ (ss/move! e :to-front)
+ (.setLocation start-point ^Point (.getPoint e)))
+ ;; When the mouse is dragged move the widget
+ ;; Unfortunately, the delta passed to this function doesn't work correctly
+ ;; if the widget is moved during the drag. So, the move is calculated
+ ;; manually.
+ :drag (fn [^MouseEvent e _]
+ (let [^Point p (.getPoint e)]
+ (ss/move! e :by [(if disable-x? 0 (- (.x p) (.x start-point)))
+ (- (.y p) (.y start-point))])))
+ ;; When the drag finishes, call the callback if set
+ :finish (fn [_]
+ (when-let [callback @on-drag-finish-callback*]
+ (callback)))))
+ w))
+
+
+(defn make-label
+ "Create a styled rounded label at the given location"
+ [location-fn text]
+ (doto
+ ;; Instead of a boring label, make the label rounded with
+ ;; some custom drawing. Use the before paint hook to draw
+ ;; under the label's text.
+ (ss/label
+ :border 5
+ :text text
+ :location (location-fn)
+ :paint {:before (fn [c g]
+ (sg/draw g (sg/rounded-rect 3
+ 3
+ (- (ss/width c) 6)
+ (- (ss/width c) 6)
+ 9)
+ (sg/style :foreground "salmon"
+ :background "#666"
+ :stroke 2)))})
+ ;; Set the bounds to its preferred size. Note that this has to be
+ ;; done after the label is fully constructed.
+ (ss/config! :bounds :preferred)))
diff --git a/src/closyr/ui/gui.clj b/src/closyr/ui/gui.clj
index 0ca19fa9..053d1c4d 100644
--- a/src/closyr/ui/gui.clj
+++ b/src/closyr/ui/gui.clj
@@ -1,25 +1,28 @@
(ns closyr.ui.gui
+ (:refer-clojure :exclude [rand rand-int rand-nth shuffle])
(:require
[clojure.core.async :as async :refer [go go-loop timeout !! ! chan put! alts!]]
- [clojure.java.io :as io]
+ [clojure.string :as str]
[closyr.dataset.inputs :as input-data]
+ [closyr.ui.components :as ui-comp]
[closyr.ui.plot :as plot]
+ [closyr.ui.settings.advanced :as settings-adv]
+ [closyr.ui.settings.experiment :as settings-exp]
+ [closyr.ui.settings.mutations :as settings-mut]
+ [closyr.ui.sketchpad :as sketchpad]
+ [closyr.ui.theme :as ui-theme]
[closyr.util.csv :as input-csv]
[closyr.util.log :as log]
- [seesaw.behave :as sb]
- [seesaw.border :as sbr]
- [seesaw.core :as ss]
- [seesaw.graphics :as sg])
+ [closyr.util.prng :refer [rand rand-int rand-nth shuffle]]
+ [seesaw.core :as ss])
(:import
- (io.materialtheme.darkstackoverflow
- DarkStackOverflowTheme)
(java.awt
BorderLayout
Color
Container
- Cursor
+ Dimension
FlowLayout
- Graphics2D
+ Font
GridBagConstraints
GridBagLayout
GridLayout
@@ -28,6 +31,7 @@
Toolkit)
(java.awt.event
ActionEvent
+ ActionListener
MouseEvent)
(java.io
File
@@ -47,21 +51,14 @@
JFrame
JLabel
JPanel
- JRadioButton
- JRadioButtonMenuItem
JTabbedPane
JTextField
SwingUtilities
- UIManager
- UnsupportedLookAndFeelException)
- (javax.swing.border
- Border)
+ UIManager)
(javax.swing.filechooser
FileNameExtensionFilter)
(javax.swing.text
AbstractDocument$DefaultDocumentEvent)
- (mdlaf
- MaterialLookAndFeel)
(org.knowm.xchart
XChartPanel
XYChart)
@@ -72,14 +69,6 @@
(set! *warn-on-reflection* true)
-(def ^:private brush-label:skinny "S")
-(def ^:private brush-label:broad "M")
-(def ^:private brush-label:huge "L")
-(def ^:private brush-label:line "Y")
-
-(def ^:private sketch-input-x-count* (atom 50))
-
-
(def ctl:start
"Button start text"
"Start")
@@ -96,149 +85,14 @@
(atom nil))
-(def ^:private xs->gap
- {200 3
- 100 6
- 50 12
- 25 24
- 20 28
- 10 56})
-
-
-(def ^:private experiment-settings*
- (atom {:max-leafs 40
- :input-iters 100
- :input-phenos-count 2000}))
-
-
-(def ^:private amount->number
- {"10" 10
- "100" 100
- "500" 500
- "1000" 1000
- "2000" 2000
- "5000" 5000
- "10000" 10000
- "1K" 1000
- "2K" 2000
- "5K" 5000
- "10K" 10000
- "20K" 20000
- "50K" 50000})
-
-
-(def ^:private sketch-input-x-scale* (atom (xs->gap @sketch-input-x-count*)))
-
-
-(def ^:private items-points-accessors* (atom {}))
-(def ^:private replace-drawing-widget!* (atom nil))
-
-
-(defn- redraw-sketch-widget!
- []
- (@replace-drawing-widget!* (:drawing-widget @items-points-accessors*)))
-
-
-(def ^:private sketchpad-size* (atom {}))
+(def ^:private objective-formula-field* (atom nil))
+(def ^:private objective-label* (atom nil))
(def ^:private input-y-fn* (atom input-data/initial-fn))
-(def ^:private new-xs?* (atom true))
-
-(def ^:private xs* (atom nil))
-
-
-(defn- sketchpad-on-click:skinny-brush
- [items x-scale ^MouseEvent e]
- (let [{items-point-setters :items-point-setters items-point-getters :items-point-getters} @items-points-accessors*]
- (doall
- (map-indexed
- (fn [i getter]
- (let [^Point pt (getter)
- setter (nth items-point-setters i)
- pt-x (.getX pt)
- pt-y (.getY pt)
- diff (/ (abs
- (- pt-x
- (.getX (.getPoint e))))
- 500.0)]
- (setter
- pt-x
- (+ (* (min 1 (+ 0.95 diff)) pt-y)
- (* (max 0 (- 0.05 diff)) (.getY (.getPoint e)))))))
- items-point-getters))))
-
-
-(defn- sketchpad-on-click:broad-brush
- [items x-scale ^MouseEvent e]
- (let [{items-point-setters :items-point-setters items-point-getters :items-point-getters} @items-points-accessors*]
- (doall
- (map-indexed
- (fn [i getter]
- (let [^Point pt (getter)
- setter (nth items-point-setters i)
- pt-x (.getX pt)
- pt-y (.getY pt)
- diff (/ (abs
- (- pt-x
- (.getX (.getPoint e))))
- 500.0)]
- (setter
- pt-x
- (+ (* (min 1 (+ 0.85 diff)) pt-y)
- (* (max 0 (- 0.15 diff)) (.getY (.getPoint e)))))))
- items-point-getters))))
-
-
-(defn- sketchpad-on-click:huge-brush
- [items x-scale ^MouseEvent e]
- (let [{items-point-setters :items-point-setters items-point-getters :items-point-getters} @items-points-accessors*]
- (doall
- (map-indexed
- (fn [i getter]
- (let [^Point pt (getter)
- setter (nth items-point-setters i)
- pt-x (.getX pt)
- pt-y (.getY pt)
-
- diff (/ (abs
- (- pt-x
- (.getX (.getPoint e))))
- 500.0)]
- (setter
- pt-x
- (+ (* (min 1 (+ 0.65 diff)) pt-y)
- (* (max 0 (- 0.35 diff)) (.getY (.getPoint e)))))))
- items-point-getters))))
-
-
-(defn- sketchpad-on-click:line-brush
- [items x-scale ^MouseEvent e]
- (let [{items-point-setters :items-point-setters items-point-getters :items-point-getters} @items-points-accessors*]
- (doall
- (map-indexed
- (fn [i getter]
- (let [^Point pt (getter)
- setter (nth items-point-setters i)
- pt-x (.getX pt)
- pt-y (.getY pt)]
- (setter pt-x (.getY (.getPoint e)))))
- items-point-getters))))
-
-
-(def ^:private brush-fn* (atom sketchpad-on-click:broad-brush))
-
-
-(def ^:private brushes-map
- {brush-label:skinny sketchpad-on-click:skinny-brush
- brush-label:broad sketchpad-on-click:broad-brush
- brush-label:huge sketchpad-on-click:huge-brush
- brush-label:line sketchpad-on-click:line-brush})
-
-
(def ^:private selectable-input-fns
- (input-data/input-y-fns-data sketchpad-size* sketch-input-x-count*))
+ (input-data/input-y-fns-data sketchpad/sketchpad-size* sketchpad/sketch-input-x-count*))
(def ^:private input-y-fns
@@ -248,6 +102,13 @@
selectable-input-fns)))
+(def ^:private input-y-formulas
+ (into {}
+ (map
+ (fn [[k v]] [k (:formula v)])
+ selectable-input-fns)))
+
+
(def ^:private dataset-fns
(->>
selectable-input-fns
@@ -255,328 +116,13 @@
(mapv first)))
-(defn- setup-theme
- []
- (try
- (UIManager/setLookAndFeel
- (MaterialLookAndFeel.
- ;; (MaterialLiteTheme.)
- ;; (JMarsDarkTheme.)
- (DarkStackOverflowTheme.)))
-
- (catch UnsupportedLookAndFeelException e
- (log/error "Theme error: " e))))
-
-
-(defn- ^JPanel panel-grid
- [{:keys [rows cols ^Border border]}]
- (let [panel (doto (JPanel. (BorderLayout.))
- (.setLayout (GridLayout. rows cols)))]
- (cond-> panel
- (not (nil? border)) (.setBorder border))
- panel))
-
-
-(defn- radio-controls-border
- [title]
- (BorderFactory/createTitledBorder (BorderFactory/createLineBorder (Color. 80 80 80) 1) title))
-
-
-(defn- movable
- ([w] (movable w {:disable-x? false}))
- ([w {disable-x? :disable-x?}]
- (let [^Point start-point (Point.)]
- (sb/when-mouse-dragged
- w
- ;; When the mouse is pressed, move the widget to the front of the z order
- :start (fn [^MouseEvent e]
- (ss/move! e :to-front)
- (.setLocation start-point ^Point (.getPoint e)))
- ;; When the mouse is dragged move the widget
- ;; Unfortunately, the delta passed to this function doesn't work correctly
- ;; if the widget is moved during the drag. So, the move is calculated
- ;; manually.
- :drag (fn [^MouseEvent e _]
- (let [^Point p (.getPoint e)]
- (ss/move! e :by [(if disable-x? 0 (- (.x p) (.x start-point)))
- (- (.y p) (.y start-point))]))))
- w)))
-
-
-(defn- make-label
- [location-fn text]
- (doto
- ;; Instead of a boring label, make the label rounded with
- ;; some custom drawing. Use the before paint hook to draw
- ;; under the label's text.
- (ss/label
- :border 5
- :text text
- :location (location-fn)
- :paint {:before (fn [c g]
- (sg/draw g (sg/rounded-rect 3
- 3
- (- (ss/width c) 6)
- (- (ss/width c) 6)
- ;; (- (ss/height c) 6)
- 9)
- (sg/style :foreground "salmon"
- :background "#666"
- :stroke 2)))})
- ;; Set the bounds to its preferred size. Note that this has to be
- ;; done after the label is fully constructed.
- (ss/config! :bounds :preferred)))
-
-
-(defn- draw-grid
- [c ^Graphics2D g]
- (let [w (ss/width c) h (ss/height c)]
- (.setColor g (Color. 98 98 98))
- (doseq [x (range 0 w 10)]
- (.drawLine g x 0 x h))
- (doseq [y (range 0 h 10)]
- (.drawLine g 0 y w y)))
- [c g])
-
-
-(defn- reposition-labels
- [[c ^Graphics2D g]]
- (let [{items-point-setters :items-point-setters items-point-getters :items-point-getters} @items-points-accessors*
- w (ss/width c)
- h (ss/height c)
- old-w (or (:w @sketchpad-size*) w)
- old-h (or (:h @sketchpad-size*) h)]
-
- (reset! sketchpad-size* {:h h :w w})
-
- ;; only on resize:
- (when (or (true? @new-xs?*)
- (not= w old-w)
- (not= h old-h))
- (reset! new-xs?* false)
- (if-let [xs @xs*]
- (mapv
- (fn [i x]
- (let [setter (nth items-point-setters i)
- getter (nth items-point-getters i)]
- (setter
- (+ 50.0 (* x (/ w 675)))
- (+ (.getY ^Point (getter))
- (if (pos? (- h old-h))
- (Math/ceil (/ (- h old-h) 2))
- (Math/floor (/ (- h old-h) 2)))))))
- (range @sketch-input-x-count*)
- xs)
-
- (mapv
- (fn [i]
- (let [setter (nth items-point-setters i)
- getter (nth items-point-getters i)]
- (setter
- (+ 50.0 (* i @sketch-input-x-scale* (/ w 675)))
- (+ (.getY ^Point (getter))
- (if (pos? (- h old-h))
- (Math/ceil (/ (- h old-h) 2))
- (Math/floor (/ (- h old-h) 2)))))))
- (range @sketch-input-x-count*))))))
-
-
-(defn- set-widget-location
- [^JLabel widget ^double x ^double y]
- (.setLocation widget x y))
-
-
-(defn- input-data-items-widget
- [points-fn]
- (log/info "Create input-data-items-widget")
- (let [^JPanel bp (doto
- (ss/border-panel
- :border (sbr/line-border :top 15 :color "#AAFFFF")
- :north (ss/label "I'm a draggable label with a text box!")
- :center (ss/text
- :text "Hey type some stuff here"
- :listen
- [:document
- (fn [^AbstractDocument$DefaultDocumentEvent e]
- (let [doc (.getDocument e)
- doc-txt (.getText doc 0 (.getLength doc))]
- (log/info "New text: " doc-txt)))]))
- (ss/config! :bounds :preferred)
- (movable))
-
-
- pts (map
- (fn [i]
- [(+ 50.0 (* i @sketch-input-x-scale*)) (points-fn i)])
- (range @sketch-input-x-count*))
-
- items (map
- (fn [pt] (movable (make-label (constantly pt) (str " ")) {:disable-x? true}))
- pts)
-
- items-point-getters (map
- (fn [^JLabel widget] (fn [] (.getLocation widget)))
- items)
-
- items-point-setters (map
- (fn [^JLabel widget]
- (fn [x y]
- (set-widget-location widget x y)))
- items)
-
- ^JPanel drawing-widget (ss/xyz-panel
- :paint (comp reposition-labels draw-grid)
- :id :xyz
- :items items #_(conj items bp)
- :listen [:mouse-clicked #(@brush-fn* items @sketch-input-x-scale* %)])]
-
- (.setCursor drawing-widget (Cursor/getPredefinedCursor Cursor/HAND_CURSOR))
- (log/info "Set hand cursor for sketchpad widget: " (.getCursor drawing-widget))
-
- (reset! items-points-accessors* {:drawing-widget drawing-widget
- :items-point-getters items-point-getters
- :items-point-setters items-point-setters})
-
- {:drawing-widget drawing-widget
- :items-point-getters items-point-getters
- :items-point-setters items-point-setters}))
-
-
-(defn- getters->input-data
- [items-point-getters]
- (mapv (fn [getter]
- (let [^Point pt (getter)]
- [(/ (- (.getX pt) 50.0) (/ (:w @sketchpad-size*) 20.0 #_@sketch-input-x-count*))
- (- 7.5 (/ (.getY pt)
- (/ (:h @sketchpad-size*) 15.0)))]))
- items-point-getters))
-
-
-(defn- settings-max-leafs-on-change
- [^MouseEvent e]
- (let [b (.getText ^JRadioButtonMenuItem (.getSource e))]
- (swap! experiment-settings* assoc :max-leafs (Integer/parseInt b))
- (log/info "max leafs changed to " b)))
-
-
-(defn- ^JPanel max-leafs-settings-panel
- []
- (let [max-leafs-settings-container (panel-grid
- {:rows 1 :cols 4 :border (radio-controls-border "Max Function Leafs")})
-
- ^JPanel settings-container (panel-grid {:rows 1 :cols 1})
-
- btn-group-max-leafs (ss/button-group)
- ^JRadioButtonMenuItem max-leafs-radio-10 (ss/radio-menu-item
- :text "20"
- :group btn-group-max-leafs
- :listen [:mouse-clicked settings-max-leafs-on-change])
- ^JRadioButtonMenuItem max-leafs-radio-100 (ss/radio-menu-item
- :selected? true
- :text "40"
- :group btn-group-max-leafs
- :listen [:mouse-clicked settings-max-leafs-on-change])
- ^JRadioButtonMenuItem max-leafs-radio-1k (ss/radio-menu-item
- :text "60"
- :group btn-group-max-leafs
- :listen [:mouse-clicked settings-max-leafs-on-change])
- ^JRadioButtonMenuItem max-leafs-radio-10k (ss/radio-menu-item
- :text "120"
- :group btn-group-max-leafs
- :listen [:mouse-clicked settings-max-leafs-on-change])]
-
-
- (.add max-leafs-settings-container max-leafs-radio-10)
- (.add max-leafs-settings-container max-leafs-radio-100)
- (.add max-leafs-settings-container max-leafs-radio-1k)
- (.add max-leafs-settings-container max-leafs-radio-10k)
- (.add settings-container max-leafs-settings-container)
- settings-container))
-
-
-(defn- settings-iters-on-change
- [^MouseEvent e]
- (let [b (.getText ^JRadioButtonMenuItem (.getSource e))]
- (swap! experiment-settings* assoc :input-iters (amount->number b))
- (log/info "iters changed to " b)))
-
-
-(defn- settings-pheno-count-on-change
- [^MouseEvent e]
- (let [b (.getText ^JRadioButtonMenuItem (.getSource e))]
- (swap! experiment-settings* assoc :input-phenos-count (amount->number b))
- (log/info "pheno count changed to " b)))
-
-
-(defn- ^JPanel experiment-settings-panel
- []
- (let [iters-settings-container (panel-grid
- {:rows 1 :cols 4 :border (radio-controls-border "Iterations")})
- pcount-settings-container (panel-grid
- {:rows 1 :cols 5 :border (radio-controls-border "Population Size")})
- ^JPanel settings-container (panel-grid {:rows 1 :cols 2})
-
- btn-group-iters (ss/button-group)
- ^JRadioButtonMenuItem iter-radio-10 (ss/radio-menu-item
- :text "10"
- :group btn-group-iters
- :listen [:mouse-clicked settings-iters-on-change])
- ^JRadioButtonMenuItem iter-radio-100 (ss/radio-menu-item
- :selected? true
- :text "100"
- :group btn-group-iters
- :listen [:mouse-clicked settings-iters-on-change])
- ^JRadioButtonMenuItem iter-radio-1k (ss/radio-menu-item
- :text "1K"
- :group btn-group-iters
- :listen [:mouse-clicked settings-iters-on-change])
- ^JRadioButtonMenuItem iter-radio-10k (ss/radio-menu-item
- :text "10K"
- :group btn-group-iters
- :listen [:mouse-clicked settings-iters-on-change])
-
- btn-group-pcounts (ss/button-group)
- ^JRadioButtonMenuItem pcount-radio-500 (ss/radio-menu-item
- :text "500"
- :group btn-group-pcounts
- :listen [:mouse-clicked settings-pheno-count-on-change])
- ^JRadioButtonMenuItem pcount-radio-1k (ss/radio-menu-item
- :text "1K"
- :group btn-group-pcounts
- :listen [:mouse-clicked settings-pheno-count-on-change])
- ^JRadioButtonMenuItem pcount-radio-2k (ss/radio-menu-item
- :text "2K"
- :selected? true
- :group btn-group-pcounts
- :listen [:mouse-clicked settings-pheno-count-on-change])
- ^JRadioButtonMenuItem pcount-radio-10k (ss/radio-menu-item
- :text "5K"
- :group btn-group-pcounts
- :listen [:mouse-clicked settings-pheno-count-on-change])
- ^JRadioButtonMenuItem pcount-radio-50k (ss/radio-menu-item
- :text "50K"
- :group btn-group-pcounts
- :listen [:mouse-clicked settings-pheno-count-on-change])]
- (.add pcount-settings-container pcount-radio-500)
- (.add pcount-settings-container pcount-radio-1k)
- (.add pcount-settings-container pcount-radio-2k)
- (.add pcount-settings-container pcount-radio-10k)
- (.add pcount-settings-container pcount-radio-50k)
-
- (.add iters-settings-container iter-radio-10)
- (.add iters-settings-container iter-radio-100)
- (.add iters-settings-container iter-radio-1k)
- (.add iters-settings-container iter-radio-10k)
- (.add settings-container iters-settings-container)
- (.add settings-container pcount-settings-container)
- settings-container))
(defn- start-stop-on-click
[sim-stop-start-chan ^JLabel status-label ^MouseEvent e]
- (let [{:keys [items-point-getters]} @items-points-accessors*
+ (let [{:keys [items-point-getters]} (sketchpad/get-items-points-accessors)
is-start (= ctl:start (ss/get-text* e))
- input-data (getters->input-data items-point-getters)
+ input-data (sketchpad/getters->input-data items-point-getters)
input-x (mapv first input-data)
input-y (mapv second input-data)]
@@ -584,7 +130,7 @@
(reset! experiment-is-running?* is-start)
(.setEnabled ^JButton @ctl-reset-btn* true)
- (put! sim-stop-start-chan (merge @experiment-settings*
+ (put! sim-stop-start-chan (merge @settings-exp/experiment-settings*
{:new-state (if is-start :start :pause)
:input-data-x input-x
:input-data-y input-y}))
@@ -596,150 +142,142 @@
(ss/set-text* status-label
(if is-start
"Running"
- "Paused"))))
+ "Paused"))
+ (.setForeground status-label
+ (if is-start
+ (Color. 0 200 0)
+ (Color. 255 180 0)))))
(defn- reset-on-click
[^JButton start-top-label sim-stop-start-chan ^JLabel status-label ^MouseEvent e]
- (let [{:keys [items-point-getters]} @items-points-accessors*
- input-data (getters->input-data items-point-getters)
+ (let [{:keys [items-point-getters]} (sketchpad/get-items-points-accessors)
+ input-data (sketchpad/getters->input-data items-point-getters)
input-x (mapv first input-data)
input-y (mapv second input-data)]
(reset! experiment-is-running?* true)
(log/info "clicked Reset")
- (put! sim-stop-start-chan (merge @experiment-settings*
+ (put! sim-stop-start-chan (merge @settings-exp/experiment-settings*
{:new-state :restart
:input-data-x input-x
:input-data-y input-y}))
(ss/set-text* start-top-label ctl:stop)
- (ss/set-text* status-label "Running")))
+ (ss/set-text* status-label "Running")
+ (.setForeground status-label (Color. 0 200 0))))
(defn- input-dataset-change
[^ActionEvent e]
- (let [{:keys [^JPanel drawing-widget items-point-setters items-point-getters]} @items-points-accessors*
- ^JComboBox jcb (.getSource e)
+ (let [^JComboBox jcb (.getSource e)
selection (-> jcb .getSelectedItem str)
- new-fn (input-y-fns selection)]
- (reset! xs* nil)
+ new-fn (input-y-fns selection)
+ new-formula (input-y-formulas selection)]
+ (reset! sketchpad/xs* nil)
(reset! input-y-fn* selection)
- (doseq [i (range @sketch-input-x-count*)]
- ((nth items-point-setters i)
- (.getX ^Point ((nth items-point-getters i)))
- (new-fn i)))
- (ss/repaint! drawing-widget)
+ (sketchpad/update-points-from-setters! new-fn)
+ ;; Reset label to show formula when selecting a dataset
+ (when-let [^JLabel label @objective-label*]
+ (.setText label "ObjectiveFn(x_) :="))
+ (when-let [^JTextField formula-field @objective-formula-field*]
+ (.setText formula-field (or new-formula "")))
+ (sketchpad/repaint-drawing-widget!)
(log/info "Selected: " selection)))
-(defn- brush-on-change
- [^MouseEvent e]
- (let [b (.getText ^JRadioButtonMenuItem (.getSource e))]
- (reset! brush-fn* (brushes-map b))
- (log/info "brush change to " b)))
+(defn- parse-seed-value
+ "Parse text as a Long seed value. Returns nil for empty/invalid input."
+ [^String text]
+ (when (and text (not (empty? (.trim text))))
+ (try
+ (Long/parseLong (.trim text))
+ (catch NumberFormatException _
+ nil))))
-(defn- xs-on-change
- [^MouseEvent e]
- (let [xs-str (.getText ^JRadioButtonMenuItem (.getSource e))
- new-xs (Integer/parseInt xs-str)]
- (reset! xs* nil)
- (reset! sketch-input-x-count* new-xs)
- (reset! sketch-input-x-scale* (xs->gap new-xs))
- (redraw-sketch-widget!)
- (log/info "brush xs to " xs-str " -> " new-xs)))
+(defn- update-seed-warning-visibility!
+ "Update the visibility and text of the warning label based on seed value."
+ [^JLabel warning-label seed-value]
+ (if seed-value
+ (do
+ (.setText warning-label "Deterministic mode: slower single-threaded")
+ (.setVisible warning-label true))
+ (do
+ (.setText warning-label "")
+ (.setVisible warning-label false))))
-(defn- ^JPanel brush-panel
- []
- (let [brush-config-container (panel-grid {:rows 1 :cols 4 :border (radio-controls-border "Brush")})
- ^JPanel brush-container (panel-grid {:rows 1 :cols 3})
-
- btn-group-brush (ss/button-group)
- ^JRadioButtonMenuItem b-radio-0 (ss/radio-menu-item
- :text brush-label:skinny
- :group btn-group-brush
- :listen [:mouse-clicked brush-on-change])
- ^JRadioButtonMenuItem b-radio-1 (ss/radio-menu-item
- :selected? true
- :text brush-label:broad
- :group btn-group-brush
- :listen [:mouse-clicked brush-on-change])
- ^JRadioButtonMenuItem b-radio-2 (ss/radio-menu-item
- :text brush-label:huge
- :group btn-group-brush
- :listen [:mouse-clicked brush-on-change])
- ^JRadioButtonMenuItem b-radio-3 (ss/radio-menu-item
- :text brush-label:line
- :group btn-group-brush
- :listen [:mouse-clicked brush-on-change])]
- (.add brush-config-container b-radio-0)
- (.add brush-config-container b-radio-1)
- (.add brush-config-container b-radio-2)
- (.add brush-config-container b-radio-3)
- (.add brush-container brush-config-container)
- brush-container))
-
-
-(defn- ^JPanel xs-panel
+(defn- ^JPanel random-seed-panel
+ "Create a panel for random seed input with performance warning and clear button."
[]
- (let [xs-config-container (panel-grid {:rows 1 :cols 5 :border (radio-controls-border "Points Count")})
- ^JPanel xs-container (panel-grid {:rows 1 :cols 1})
-
- btn-group-xs (ss/button-group)
- ^JRadioButtonMenuItem xs-radio-10 (ss/radio-menu-item
- :text "10"
- :group btn-group-xs
- :listen [:mouse-clicked xs-on-change])
- ^JRadioButtonMenuItem xs-radio-25 (ss/radio-menu-item
- :text "25"
- :group btn-group-xs
- :listen [:mouse-clicked xs-on-change])
- ^JRadioButtonMenuItem xs-radio-50 (ss/radio-menu-item
- :selected? true
- :text "50"
- :group btn-group-xs
- :listen [:mouse-clicked xs-on-change])
- ^JRadioButtonMenuItem xs-radio-100 (ss/radio-menu-item
- :text "100"
- :group btn-group-xs
- :listen [:mouse-clicked xs-on-change])
-
- ^JRadioButtonMenuItem xs-radio-200 (ss/radio-menu-item
- :text "200"
- :group btn-group-xs
- :listen [:mouse-clicked xs-on-change])]
- (.add xs-config-container xs-radio-10)
- (.add xs-config-container xs-radio-25)
- (.add xs-config-container xs-radio-50)
- (.add xs-config-container xs-radio-100)
- ;; (.add xs-config-container xs-radio-200)
- (.add xs-container xs-config-container)
- xs-container))
+ (let [^JPanel container (ui-comp/panel-grid {:rows 1 :cols 1 :border (ui-comp/radio-controls-border "Random Seed")})
+ ^JPanel inner-panel (doto (JPanel.)
+ (.setLayout (FlowLayout. FlowLayout/LEFT 5 2)))
+
+ ^JLabel seed-label (JLabel. "Seed:")
+ ^JTextField seed-field (doto (JTextField. 10)
+ (.setToolTipText "Enter a number for deterministic mode, or leave empty for parallel mode"))
+
+ ^JButton clear-btn (doto ^JButton (ss/button :text "Clear (Parallel)")
+ (.setToolTipText "Clear seed to return to parallel (non-deterministic) mode"))
+
+ ^JLabel warning-label (doto (JLabel. "")
+ (.setForeground (Color. 255 180 0))
+ (.setVisible false))
+
+ update-seed! (fn [seed-value]
+ (swap! settings-exp/experiment-settings* assoc :random-seed seed-value)
+ (update-seed-warning-visibility! warning-label seed-value)
+ (log/info "Random seed changed to:" seed-value
+ (if seed-value "(deterministic mode)" "(parallel mode)")))]
+
+ ;; Listen for text changes in the seed field
+ (ss/listen seed-field
+ :document
+ (fn [^AbstractDocument$DefaultDocumentEvent e]
+ (let [doc (.getDocument e)
+ doc-txt (.getText doc 0 (.getLength doc))
+ new-seed (parse-seed-value doc-txt)]
+ (update-seed! new-seed))))
+
+ ;; Clear button resets to parallel mode
+ (ss/listen clear-btn
+ :mouse-clicked
+ (fn [^MouseEvent _]
+ (.setText seed-field "")
+ (update-seed! nil)))
+
+ ;; Build the panel
+ (.add inner-panel seed-label)
+ (.add inner-panel seed-field)
+ (.add inner-panel clear-btn)
+ (.add inner-panel warning-label)
+ (.add container inner-panel)
+ container))
+
+
(defn- update-replace-drawing-widget
[draw-container]
- (reset!
- replace-drawing-widget!*
+ (sketchpad/set-replace-drawing-widget-fn!
(fn [^JPanel drawing-widget]
(log/info "REPLACE DRAWING WIDGET!")
- (reset! new-xs?* true)
(ss/replace!
draw-container
drawing-widget
(:drawing-widget
- (input-data-items-widget
+ (sketchpad/input-data-items-widget
(input-y-fns @input-y-fn*)))))))
(defn- set-input-data!
[input-data-maps]
- (let [{:keys [^JPanel drawing-widget]} @items-points-accessors*
+ (let [{:keys [^JPanel drawing-widget]} (sketchpad/get-items-points-accessors)
canvas-w (ss/width drawing-widget)
canvas-h (ss/height drawing-widget)]
- (reset! sketch-input-x-count* (count input-data-maps))
- (redraw-sketch-widget!)
- (let [{:keys [items-point-setters items-point-getters]} @items-points-accessors*
+ (sketchpad/set-xs-count! (count input-data-maps))
+ (sketchpad/redraw-sketch-widget!)
+ (let [{:keys [items-point-setters items-point-getters]} (sketchpad/get-items-points-accessors)
xs (map :x input-data-maps)
ys (map :y input-data-maps)
max-x (reduce max xs)
@@ -757,16 +295,15 @@
{:x (* x-scalar x)
:y (* y-scalar y)})
input-data-maps)]
- (reset! xs* (mapv :x scaled-inputs))
+ (reset! sketchpad/xs* (mapv :x scaled-inputs))
(doseq [[i {:keys [x y]}] (map-indexed (fn [i d] [i d]) scaled-inputs)]
- (let []
- (log/info "Set ixy: " i x y
- " scalars: " x-scalar y-scalar
- " diff: " diff-x diff-y
- " canvas: " canvas-w canvas-h)
- ((nth items-point-setters i)
- x
- (input-data/y->gui-coord-y sketchpad-size* y)))))))
+ (log/info "Set ixy: " i x y
+ " scalars: " x-scalar y-scalar
+ " diff: " diff-x diff-y
+ " canvas: " canvas-w canvas-h)
+ ((nth items-point-setters i)
+ x
+ (input-data/y->gui-coord-y sketchpad/sketchpad-size* y))))))
(defn- ^JPanel input-file-picker-widget
@@ -801,17 +338,12 @@
(ss/set-text* input-file-label
(str "Error: " (.getMessage e))))))))])
- ^JPanel input-file-container (doto (panel-grid {:rows 2 :cols 1})
+ ^JPanel input-file-container (doto (ui-comp/panel-grid {:rows 2 :cols 1})
(.add select-file-button)
(.add input-file-label))]
input-file-container))
-(defn- set-app-icon
- [^JFrame frame]
- (let [^Image icon (.getImage (Toolkit/getDefaultToolkit) (io/resource "icons/icon_v5_qtr.png"))]
- (doto frame
- (.setIconImage icon))))
(defn- setup-ui-frame
@@ -834,25 +366,56 @@
^List xs-scores-p90
^List ys-scores-p90]
:as gui-data}]
- (let [my-frame (doto (JFrame. "CLOSYR")
+ (let [my-frame-atom (atom nil)
+ my-frame (doto (JFrame. "CLOSYR")
(.setDefaultCloseOperation JFrame/EXIT_ON_CLOSE #_DISPOSE_ON_CLOSE)
- (set-app-icon))
+ (ui-theme/set-app-icon))
- bottom-container (panel-grid {:rows 2 :cols 1})
- inputs-and-info-container (panel-grid {:rows 3 :cols 1})
- ctls-container (panel-grid {:rows 2 :cols 1})
- row-3-container (panel-grid {:rows 1 :cols 2})
- draw-parent (panel-grid {:rows 1 :cols 1})
- top-container (panel-grid {:rows 1 :cols 2})
- input-fn-container (panel-grid {:rows 1 :cols 1})
+ bottom-container (ui-comp/panel-grid {:rows 2 :cols 1})
+ inputs-and-info-container (ui-comp/panel-grid {:rows 3 :cols 1})
+ ctls-container (ui-comp/panel-grid {:rows 2 :cols 1})
+ row-3-container (ui-comp/panel-grid {:rows 1 :cols 2})
+ draw-parent (ui-comp/panel-grid {:rows 1 :cols 1})
+ top-container (ui-comp/panel-grid {:rows 1 :cols 2})
+ input-fn-container (ui-comp/panel-grid {:rows 1 :cols 1})
- page-pane (panel-grid {:rows 2 :cols 1})
+ page-pane (ui-comp/panel-grid {:rows 2 :cols 1})
content-pane (doto (.getContentPane my-frame)
(.setLayout (GridLayout. 1 1)))
- sim-info-label (JLabel. "")
- ^JTextField best-fn-selectable-text (doto (JTextField. "")
- (.setEditable false))
+ unicode-font (ui-theme/find-unicode-font 14)
+ label-width 130
+ sim-info-label (let [lbl (JLabel. "")]
+ (when unicode-font
+ (.setFont lbl unicode-font))
+ lbl)
+ initial-formula (str (or (input-y-formulas input-data/initial-fn) ""))
+ ^JLabel objective-label (let [lbl (doto (JLabel. "ObjectiveFn(x_) :=")
+ (.setPreferredSize (Dimension. label-width 20)))]
+ (when unicode-font
+ (.setFont lbl unicode-font))
+ (reset! objective-label* lbl))
+ ^JTextField objective-formula-text (let [tf (doto (JTextField. initial-formula)
+ (.setEditable false))]
+ (when unicode-font
+ (.setFont tf unicode-font))
+ (reset! objective-formula-field* tf))
+ ^JPanel objective-row (doto (JPanel. (BorderLayout.))
+ (.add objective-label BorderLayout/WEST)
+ (.add objective-formula-text BorderLayout/CENTER))
+ ^JLabel best-label (let [lbl (doto (JLabel. "BestFitFn(x_) :=")
+ (.setPreferredSize (Dimension. label-width 20)))]
+ (when unicode-font
+ (.setFont lbl unicode-font))
+ lbl)
+ ^JTextField best-fn-selectable-text (let [tf (doto (JTextField. "")
+ (.setEditable false))]
+ (when unicode-font
+ (.setFont tf unicode-font))
+ tf)
+ ^JPanel best-row (doto (JPanel. (BorderLayout.))
+ (.add best-label BorderLayout/WEST)
+ (.add best-fn-selectable-text BorderLayout/CENTER))
^XYChart best-fn-chart (plot/make-plot:n-series
{:x-axis-title "X"
@@ -897,12 +460,27 @@
:height 200})
scores-chart-panel (XChartPanel. scores-chart)
- {:keys [^JPanel drawing-widget]} (input-data-items-widget (input-y-fns @input-y-fn*))
-
- status-label (JLabel. "Press Start To Begin Function Search")
- status-column (doto (panel-grid {:rows 2 :cols 1})
- (.add status-label)
- (.add (max-leafs-settings-panel)))
+ {:keys [^JPanel drawing-widget]} (sketchpad/input-data-items-widget (input-y-fns @input-y-fn*))
+
+ status-label (doto (JLabel. "Press Start To Find Function")
+ (.setFont (Font. "SansSerif" Font/BOLD 18))
+ (.setForeground (Color. 180 180 180)))
+ adv-settings-value-label (JLabel. "Auto")
+ ^JButton gear-btn (doto ^JButton (ss/button
+ :text "\u2699"
+ :listen [:mouse-clicked
+ (fn [_]
+ (when-let [frame @my-frame-atom]
+ (settings-adv/show-advanced-settings-dialog! frame adv-settings-value-label settings-exp/experiment-settings*)))])
+ (.setToolTipText "Advanced settings")
+ (.setFont (Font. "SansSerif" Font/PLAIN 16))
+ (.setPreferredSize (Dimension. 40 30)))
+ status-with-gear (doto (JPanel. (BorderLayout.))
+ (.add status-label BorderLayout/CENTER)
+ (.add gear-btn BorderLayout/EAST))
+ status-column (doto (ui-comp/panel-grid {:rows 2 :cols 1})
+ (.add status-with-gear)
+ (.add (settings-exp/max-leafs-settings-panel)))
^JButton ctl-start-stop-btn (ss/button
:text ctl:start
@@ -920,20 +498,20 @@
sim-stop-start-chan
status-label)])
(.setEnabled false)))
- brush-container (brush-panel)
- xs-container (xs-panel)
- settings-panel (experiment-settings-panel)
+ brush-container (sketchpad/brush-panel)
+ xs-container (sketchpad/xs-panel)
+ settings-panel (settings-exp/experiment-settings-panel)
^JComboBox input-fn-picker (ss/combobox
:model dataset-fns
:listen [:action input-dataset-change])
icon-test (JLabel. ^Icon (UIManager/getIcon "OptionPane.informationIcon"))
- btns-row (doto (panel-grid {:rows 1 :cols 2})
+ btns-row (doto (ui-comp/panel-grid {:rows 1 :cols 2})
(.add ctl-start-stop-btn)
(.add ctl-reset-btn))
- status-row (panel-grid {:rows 1 :cols 2})
+ status-row (ui-comp/panel-grid {:rows 1 :cols 2})
^JPanel input-file-container (input-file-picker-widget status-row)
@@ -941,15 +519,25 @@
(.add status-column)
(.add input-file-container))
- btns-container (doto (panel-grid {:rows 2 :cols 1})
+ btns-container (doto (ui-comp/panel-grid {:rows 2 :cols 1})
(.add btns-row)
(.add status-row))
- settings-container (doto (panel-grid {:rows 2 :cols 1})
+ ^JPanel random-seed-panel-widget (random-seed-panel)
+
+ ^JPanel mutations-panel-widget (settings-mut/mutations-selection-panel my-frame-atom settings-exp/experiment-settings*)
+
+ ;; Combine random seed and mutations panels on the same row
+ seed-and-mutations-row (doto (ui-comp/panel-grid {:rows 1 :cols 2})
+ (.add random-seed-panel-widget)
+ (.add mutations-panel-widget))
+
+ settings-container (doto (ui-comp/panel-grid {:rows 3 :cols 1})
+ (.add seed-and-mutations-row)
(.add settings-panel)
(.add input-fn-container))
- history-container (doto (panel-grid {:rows 1 :cols 1})
+ history-container (doto (ui-comp/panel-grid {:rows 1 :cols 1})
(.add (JLabel. "Hello")))
page-pane-tabbed (doto (JTabbedPane.)
@@ -965,7 +553,8 @@
(.add input-fn-container brush-container)
(.add inputs-and-info-container sim-info-label)
- (.add inputs-and-info-container best-fn-selectable-text)
+ (.add inputs-and-info-container objective-row)
+ (.add inputs-and-info-container best-row)
(.add draw-parent drawing-widget)
(.add row-3-container draw-parent)
@@ -985,10 +574,23 @@
(.add page-pane bottom-container)
(.add content-pane page-pane)
+ ;; Set up callback to update ObjectiveFn field when sketchpad data changes
+ (sketchpad/set-on-data-change-callback!
+ (fn [y-values]
+ (.setText objective-label "InputData(y_) :=")
+ (.setText objective-formula-text
+ (str "[" (str/join ", " (map #(format "%.2f" %) y-values)) "]"))))
+
+ ;; Connect drag finish to data change notification
+ (sketchpad/init-drag-callback!)
+
(.pack my-frame)
(.setVisible my-frame true)
(.setSize my-frame 1500 800)
+ ;; Set the frame atom so dialogs can reference it
+ (reset! my-frame-atom my-frame)
+
(update-loop
{:best-fn-chart best-fn-chart
:best-fn-chart-panel best-fn-chart-panel
@@ -1007,7 +609,7 @@
(SwingUtilities/invokeLater
(fn []
(try
- (setup-theme)
+ (ui-theme/setup-theme)
(setup-ui-frame gui-data)
(catch Exception e
(log/error "Error in GUI: " e))))))
@@ -1061,7 +663,7 @@
(log/info "Test GUI: Resuming: " ( (ss/frame :title "Hello",
:width 1600
diff --git a/src/closyr/ui/settings/advanced.clj b/src/closyr/ui/settings/advanced.clj
new file mode 100644
index 00000000..cc14e9f1
--- /dev/null
+++ b/src/closyr/ui/settings/advanced.clj
@@ -0,0 +1,180 @@
+(ns closyr.ui.settings.advanced
+ "Advanced settings dialog for log interval configuration"
+ (:require
+ [closyr.util.log :as log])
+ (:import
+ (java.awt
+ BorderLayout
+ FlowLayout)
+ (java.awt.event
+ ActionListener
+ ItemListener
+ ItemEvent)
+ (javax.swing
+ BoxLayout
+ ButtonGroup
+ JButton
+ JCheckBox
+ JComboBox
+ JDialog
+ JFrame
+ JLabel
+ JPanel
+ JRadioButton)))
+
+
+(set! *warn-on-reflection* true)
+
+
+(def ^:private scoring-method-options
+ "Available scoring methods with display names"
+ [["MAE + Max (default)" :mae-max]
+ ["Log-Cosh (robust)" :log-cosh]
+ ["R² (variance)" :r-squared]])
+
+
+(defn show-advanced-settings-dialog!
+ "Show a dialog with advanced settings for log interval, adaptive mode, and quiet logging"
+ [^JFrame parent-frame ^JLabel current-value-label experiment-settings*]
+ (let [^JDialog dialog (doto (JDialog. parent-frame "Advanced Settings" true)
+ (.setSize 400 420)
+ (.setLocationRelativeTo parent-frame))
+
+ current-log-steps (:log-steps @experiment-settings*)
+ current-adaptive (:adaptive-mode @experiment-settings*)
+ current-quiet (:quiet-logs @experiment-settings*)
+ current-eval-cache (:use-eval-cache @experiment-settings*)
+ current-scoring (:scoring-method @experiment-settings* :mae-max)
+ selected-value (atom current-log-steps)
+ selected-adaptive (atom current-adaptive)
+ selected-quiet (atom current-quiet)
+ selected-eval-cache (atom current-eval-cache)
+ selected-scoring (atom current-scoring)
+
+ btn-group (ButtonGroup.)
+ options [["Auto" nil] ["1" 1] ["5" 5] ["10" 10] ["25" 25]]
+
+ ^JPanel radio-panel (JPanel.)
+ _ (.setLayout radio-panel (BoxLayout. radio-panel BoxLayout/Y_AXIS))
+
+ _ (doseq [[label value] options]
+ (let [^JRadioButton rb (doto (JRadioButton. ^String label)
+ (.setSelected (= value current-log-steps))
+ (.addActionListener
+ (reify ActionListener
+ (actionPerformed [_ _]
+ (reset! selected-value value)))))]
+ (.add btn-group rb)
+ (.add radio-panel rb)))
+
+ ;; Adaptive mode checkbox
+ ^JCheckBox adaptive-cb (JCheckBox. "Adaptive Mutations")
+ _ (doto adaptive-cb
+ (.setSelected (boolean current-adaptive))
+ (.setToolTipText "Dynamically adjust mutation rates based on population diversity and stagnation")
+ (.addActionListener
+ (reify ActionListener
+ (actionPerformed [_ _]
+ (reset! selected-adaptive (.isSelected adaptive-cb))))))
+
+ ;; Quiet logging checkbox
+ ^JCheckBox quiet-cb (JCheckBox. "Quiet Logging")
+ _ (doto quiet-cb
+ (.setSelected (boolean current-quiet))
+ (.setToolTipText "Suppress detailed iteration logs for cleaner output")
+ (.addActionListener
+ (reify ActionListener
+ (actionPerformed [_ _]
+ (reset! selected-quiet (.isSelected quiet-cb))))))
+
+ ;; Eval cache checkbox
+ ^JCheckBox eval-cache-cb (JCheckBox. "Evaluation Cache")
+ _ (doto eval-cache-cb
+ (.setSelected (boolean current-eval-cache))
+ (.setToolTipText "Cache evaluation results by expression to avoid redundant calculations")
+ (.addActionListener
+ (reify ActionListener
+ (actionPerformed [_ _]
+ (reset! selected-eval-cache (.isSelected eval-cache-cb))))))
+
+ ;; Scoring method dropdown
+ ^"[Ljava.lang.Object;" scoring-labels (into-array Object (mapv first scoring-method-options))
+ ^JComboBox scoring-combo (JComboBox. scoring-labels)
+ current-scoring-idx (or (first (keep-indexed
+ (fn [i [_ v]] (when (= v current-scoring) i))
+ scoring-method-options))
+ 0)
+ _ (doto scoring-combo
+ (.setSelectedIndex current-scoring-idx)
+ (.setToolTipText "Scoring method for fitness evaluation")
+ (.addItemListener
+ (reify ItemListener
+ (itemStateChanged [_ e]
+ (when (= (.getStateChange e) ItemEvent/SELECTED)
+ (let [idx (.getSelectedIndex scoring-combo)
+ [_ method] (nth scoring-method-options idx)]
+ (reset! selected-scoring method)))))))
+
+ ;; Scoring panel
+ ^JPanel scoring-panel (JPanel. (FlowLayout. FlowLayout/LEFT))
+ _ (doto scoring-panel
+ (.add (JLabel. "Scoring: "))
+ (.add scoring-combo))
+
+ ;; Checkboxes panel
+ ^JPanel checkbox-panel (JPanel.)
+ _ (doto checkbox-panel
+ (.setLayout (BoxLayout. checkbox-panel BoxLayout/Y_AXIS))
+ (.add adaptive-cb)
+ (.add quiet-cb)
+ (.add eval-cache-cb))
+
+ ^JButton ok-btn (doto (JButton. "OK")
+ (.addActionListener
+ (reify ActionListener
+ (actionPerformed [_ _]
+ (swap! experiment-settings* assoc
+ :log-steps @selected-value
+ :adaptive-mode @selected-adaptive
+ :quiet-logs @selected-quiet
+ :use-eval-cache @selected-eval-cache
+ :scoring-method @selected-scoring)
+ (.setText current-value-label (if @selected-value
+ (str @selected-value)
+ "Auto"))
+ (log/info "Log steps changed to:" (or @selected-value "Auto"))
+ (log/info "Adaptive mode:" @selected-adaptive)
+ (log/info "Quiet logging:" @selected-quiet)
+ (log/info "Eval cache:" @selected-eval-cache)
+ (log/info "Scoring method:" @selected-scoring)
+ (.dispose dialog)))))
+
+ ^JButton cancel-btn (doto (JButton. "Cancel")
+ (.addActionListener
+ (reify ActionListener
+ (actionPerformed [_ _]
+ (.dispose dialog)))))
+
+ ^JPanel buttons-panel (doto (JPanel. (FlowLayout.))
+ (.add ok-btn)
+ (.add cancel-btn))
+
+ ;; Center panel with both sections
+ ^JPanel center-panel (JPanel.)
+ _ (doto center-panel
+ (.setLayout (BoxLayout. center-panel BoxLayout/Y_AXIS))
+ (.add (JLabel. "Log/Chart Update Interval (iterations):"))
+ (.add radio-panel)
+ (.add (JLabel. " "))
+ (.add (JLabel. "Mutation Settings:"))
+ (.add checkbox-panel)
+ (.add (JLabel. " "))
+ (.add (JLabel. "Scoring:"))
+ (.add scoring-panel))
+
+ ^JPanel main-panel (doto (JPanel. (BorderLayout.))
+ (.add center-panel BorderLayout/CENTER)
+ (.add buttons-panel BorderLayout/SOUTH))]
+
+ (.setContentPane dialog main-panel)
+ (.setVisible dialog true)))
diff --git a/src/closyr/ui/settings/experiment.clj b/src/closyr/ui/settings/experiment.clj
new file mode 100644
index 00000000..2e5fb2a7
--- /dev/null
+++ b/src/closyr/ui/settings/experiment.clj
@@ -0,0 +1,179 @@
+(ns closyr.ui.settings.experiment
+ "Experiment settings panels for max leafs, iterations, and population size"
+ (:require
+ [closyr.ui.components :as ui-comp]
+ [closyr.util.log :as log]
+ [seesaw.core :as ss])
+ (:import
+ (java.awt.event
+ MouseEvent)
+ (javax.swing
+ JPanel
+ JRadioButtonMenuItem)))
+
+
+(set! *warn-on-reflection* true)
+
+
+;; =============================================================================
+;; State
+;; =============================================================================
+
+(def experiment-settings*
+ "Atom containing experiment settings"
+ (atom {:max-leafs 40
+ :input-iters 100
+ :input-phenos-count 2000
+ :random-seed nil
+ :mutations-blacklist nil
+ :log-steps nil
+ :adaptive-mode false
+ :quiet-logs false
+ :use-eval-cache false
+ :scoring-method :mae-max}))
+
+
+(def ^:private amount->number
+ {"10" 10
+ "100" 100
+ "500" 500
+ "1000" 1000
+ "2000" 2000
+ "5000" 5000
+ "10000" 10000
+ "1K" 1000
+ "2K" 2000
+ "5K" 5000
+ "10K" 10000
+ "20K" 20000
+ "50K" 50000})
+
+
+;; =============================================================================
+;; Event handlers
+;; =============================================================================
+
+(defn- settings-max-leafs-on-change
+ [^MouseEvent e]
+ (let [b (.getText ^JRadioButtonMenuItem (.getSource e))]
+ (swap! experiment-settings* assoc :max-leafs (Integer/parseInt b))
+ (log/info "max leafs changed to " b)))
+
+
+(defn- settings-iters-on-change
+ [^MouseEvent e]
+ (let [b (.getText ^JRadioButtonMenuItem (.getSource e))]
+ (swap! experiment-settings* assoc :input-iters (amount->number b))
+ (log/info "iters changed to " b)))
+
+
+(defn- settings-pheno-count-on-change
+ [^MouseEvent e]
+ (let [b (.getText ^JRadioButtonMenuItem (.getSource e))]
+ (swap! experiment-settings* assoc :input-phenos-count (amount->number b))
+ (log/info "pheno count changed to " b)))
+
+
+;; =============================================================================
+;; Panels
+;; =============================================================================
+
+(defn ^JPanel max-leafs-settings-panel
+ "Create the max function leafs settings panel"
+ []
+ (let [max-leafs-settings-container (ui-comp/panel-grid
+ {:rows 1 :cols 4 :border (ui-comp/radio-controls-border "Max Function Leafs")})
+
+ ^JPanel settings-container (ui-comp/panel-grid {:rows 1 :cols 1})
+
+ btn-group-max-leafs (ss/button-group)
+ ^JRadioButtonMenuItem max-leafs-radio-10 (ss/radio-menu-item
+ :text "20"
+ :group btn-group-max-leafs
+ :listen [:mouse-clicked settings-max-leafs-on-change])
+ ^JRadioButtonMenuItem max-leafs-radio-100 (ss/radio-menu-item
+ :selected? true
+ :text "40"
+ :group btn-group-max-leafs
+ :listen [:mouse-clicked settings-max-leafs-on-change])
+ ^JRadioButtonMenuItem max-leafs-radio-1k (ss/radio-menu-item
+ :text "60"
+ :group btn-group-max-leafs
+ :listen [:mouse-clicked settings-max-leafs-on-change])
+ ^JRadioButtonMenuItem max-leafs-radio-10k (ss/radio-menu-item
+ :text "120"
+ :group btn-group-max-leafs
+ :listen [:mouse-clicked settings-max-leafs-on-change])]
+
+
+ (.add max-leafs-settings-container max-leafs-radio-10)
+ (.add max-leafs-settings-container max-leafs-radio-100)
+ (.add max-leafs-settings-container max-leafs-radio-1k)
+ (.add max-leafs-settings-container max-leafs-radio-10k)
+ (.add settings-container max-leafs-settings-container)
+ settings-container))
+
+
+(defn ^JPanel experiment-settings-panel
+ "Create the experiment settings panel with iterations and population size"
+ []
+ (let [iters-settings-container (ui-comp/panel-grid
+ {:rows 1 :cols 4 :border (ui-comp/radio-controls-border "Iterations")})
+ pcount-settings-container (ui-comp/panel-grid
+ {:rows 1 :cols 5 :border (ui-comp/radio-controls-border "Population Size")})
+ ^JPanel settings-container (ui-comp/panel-grid {:rows 1 :cols 2})
+
+ btn-group-iters (ss/button-group)
+ ^JRadioButtonMenuItem iter-radio-10 (ss/radio-menu-item
+ :text "10"
+ :group btn-group-iters
+ :listen [:mouse-clicked settings-iters-on-change])
+ ^JRadioButtonMenuItem iter-radio-100 (ss/radio-menu-item
+ :selected? true
+ :text "100"
+ :group btn-group-iters
+ :listen [:mouse-clicked settings-iters-on-change])
+ ^JRadioButtonMenuItem iter-radio-1k (ss/radio-menu-item
+ :text "1K"
+ :group btn-group-iters
+ :listen [:mouse-clicked settings-iters-on-change])
+ ^JRadioButtonMenuItem iter-radio-10k (ss/radio-menu-item
+ :text "10K"
+ :group btn-group-iters
+ :listen [:mouse-clicked settings-iters-on-change])
+
+ btn-group-pcounts (ss/button-group)
+ ^JRadioButtonMenuItem pcount-radio-500 (ss/radio-menu-item
+ :text "500"
+ :group btn-group-pcounts
+ :listen [:mouse-clicked settings-pheno-count-on-change])
+ ^JRadioButtonMenuItem pcount-radio-1k (ss/radio-menu-item
+ :text "1K"
+ :group btn-group-pcounts
+ :listen [:mouse-clicked settings-pheno-count-on-change])
+ ^JRadioButtonMenuItem pcount-radio-2k (ss/radio-menu-item
+ :text "2K"
+ :selected? true
+ :group btn-group-pcounts
+ :listen [:mouse-clicked settings-pheno-count-on-change])
+ ^JRadioButtonMenuItem pcount-radio-10k (ss/radio-menu-item
+ :text "5K"
+ :group btn-group-pcounts
+ :listen [:mouse-clicked settings-pheno-count-on-change])
+ ^JRadioButtonMenuItem pcount-radio-50k (ss/radio-menu-item
+ :text "50K"
+ :group btn-group-pcounts
+ :listen [:mouse-clicked settings-pheno-count-on-change])]
+ (.add pcount-settings-container pcount-radio-500)
+ (.add pcount-settings-container pcount-radio-1k)
+ (.add pcount-settings-container pcount-radio-2k)
+ (.add pcount-settings-container pcount-radio-10k)
+ (.add pcount-settings-container pcount-radio-50k)
+
+ (.add iters-settings-container iter-radio-10)
+ (.add iters-settings-container iter-radio-100)
+ (.add iters-settings-container iter-radio-1k)
+ (.add iters-settings-container iter-radio-10k)
+ (.add settings-container iters-settings-container)
+ (.add settings-container pcount-settings-container)
+ settings-container))
diff --git a/src/closyr/ui/settings/mutations.clj b/src/closyr/ui/settings/mutations.clj
new file mode 100644
index 00000000..e3a89d4a
--- /dev/null
+++ b/src/closyr/ui/settings/mutations.clj
@@ -0,0 +1,132 @@
+(ns closyr.ui.settings.mutations
+ "Mutation selection dialog and panel"
+ (:require
+ [closyr.ops.initialize :as ops-init]
+ [closyr.ui.components :as ui-comp]
+ [closyr.util.log :as log])
+ (:import
+ (java.awt
+ BorderLayout
+ FlowLayout)
+ (java.awt.event
+ ActionListener)
+ (javax.swing
+ BoxLayout
+ JButton
+ JCheckBox
+ JDialog
+ JFrame
+ JLabel
+ JPanel
+ JScrollPane)))
+
+
+(set! *warn-on-reflection* true)
+
+
+(def all-mutation-labels
+ "All available mutation labels"
+ (ops-init/mutation-labels))
+
+
+(def selected-mutations*
+ "Set of currently selected mutation labels (all selected by default)"
+ (atom (set all-mutation-labels)))
+
+
+(defn update-mutations-blacklist!
+ "Update the mutations blacklist in experiment-settings based on deselected mutations"
+ [experiment-settings*]
+ (let [selected @selected-mutations*
+ blacklist (vec (remove selected all-mutation-labels))]
+ (swap! experiment-settings* assoc :mutations-blacklist
+ (when (seq blacklist) blacklist))
+ (log/info "Mutations blacklist updated:" (count blacklist) "mutations excluded")))
+
+
+(defn show-mutations-dialog!
+ "Show a dialog to select/deselect mutations"
+ [^JFrame parent-frame ^JLabel count-label experiment-settings*]
+ (let [^JDialog dialog (doto (JDialog. parent-frame "Select Mutations" true)
+ (.setSize 400 500)
+ (.setLocationRelativeTo parent-frame))
+
+ checkboxes (atom {})
+
+ ^JPanel checkbox-panel (JPanel.)
+ _ (.setLayout checkbox-panel (BoxLayout. checkbox-panel BoxLayout/Y_AXIS))
+
+ _ (doseq [^String label (sort all-mutation-labels)]
+ (let [^JCheckBox cb (doto (JCheckBox. label ^Boolean (contains? @selected-mutations* label))
+ (.addActionListener
+ (reify ActionListener
+ (actionPerformed [_ e]
+ (let [selected? (.isSelected ^JCheckBox (.getSource e))]
+ (if selected?
+ (swap! selected-mutations* conj label)
+ (swap! selected-mutations* disj label)))))))]
+ (swap! checkboxes assoc label cb)
+ (.add checkbox-panel cb)))
+
+ ^JScrollPane scroll-pane (doto (JScrollPane. checkbox-panel)
+ (.setVerticalScrollBarPolicy JScrollPane/VERTICAL_SCROLLBAR_ALWAYS))
+
+ ^JButton select-all-btn (doto (JButton. "Select All")
+ (.addActionListener
+ (reify ActionListener
+ (actionPerformed [_ _]
+ (reset! selected-mutations* (set all-mutation-labels))
+ (doseq [[_ ^JCheckBox cb] @checkboxes]
+ (.setSelected cb true))))))
+
+ ^JButton select-none-btn (doto (JButton. "Select None")
+ (.addActionListener
+ (reify ActionListener
+ (actionPerformed [_ _]
+ (reset! selected-mutations* #{})
+ (doseq [[_ ^JCheckBox cb] @checkboxes]
+ (.setSelected cb false))))))
+
+ ^JButton ok-btn (doto (JButton. "OK")
+ (.addActionListener
+ (reify ActionListener
+ (actionPerformed [_ _]
+ (update-mutations-blacklist! experiment-settings*)
+ (.setText count-label (str (count @selected-mutations*) "/" (count all-mutation-labels)))
+ (.dispose dialog)))))
+
+ ^JPanel buttons-panel (doto (JPanel. (FlowLayout.))
+ (.add select-all-btn)
+ (.add select-none-btn)
+ (.add ok-btn))
+
+ ^JPanel main-panel (doto (JPanel. (BorderLayout.))
+ (.add (JLabel. "Select mutations to use during evolution:") BorderLayout/NORTH)
+ (.add scroll-pane BorderLayout/CENTER)
+ (.add buttons-panel BorderLayout/SOUTH))]
+
+ (.setContentPane dialog main-panel)
+ (.setVisible dialog true)))
+
+
+(defn ^JPanel mutations-selection-panel
+ "Create a panel with a button to open mutation selection dialog"
+ [parent-frame-atom experiment-settings*]
+ (let [^JPanel container (ui-comp/panel-grid {:rows 1 :cols 1 :border (ui-comp/radio-controls-border "Mutations")})
+ ^JPanel inner-panel (doto (JPanel.)
+ (.setLayout (FlowLayout. FlowLayout/LEFT 5 2)))
+
+ ^JLabel count-label (JLabel. (str (count @selected-mutations*) "/" (count all-mutation-labels)))
+
+ ^JButton select-btn (doto (JButton. "Select Mutations...")
+ (.setToolTipText "Choose which mutations to use during evolution")
+ (.addActionListener
+ (reify ActionListener
+ (actionPerformed [_ _]
+ (when-let [frame @parent-frame-atom]
+ (show-mutations-dialog! frame count-label experiment-settings*))))))]
+
+ (.add inner-panel select-btn)
+ (.add inner-panel count-label)
+ (.add container inner-panel)
+ container))
diff --git a/src/closyr/ui/sketchpad.clj b/src/closyr/ui/sketchpad.clj
new file mode 100644
index 00000000..2410c42f
--- /dev/null
+++ b/src/closyr/ui/sketchpad.clj
@@ -0,0 +1,442 @@
+(ns closyr.ui.sketchpad
+ "Sketchpad drawing widget and brush controls"
+ (:require
+ [closyr.ui.components :as ui-comp]
+ [closyr.util.log :as log]
+ [seesaw.core :as ss])
+ (:import
+ (java.awt
+ Color
+ Cursor
+ Graphics2D
+ Point)
+ (java.awt.event
+ MouseEvent)
+ (javax.swing
+ JLabel
+ JPanel
+ JRadioButtonMenuItem)))
+
+
+(set! *warn-on-reflection* true)
+
+
+;; =============================================================================
+;; Brush labels
+;; =============================================================================
+
+(def ^:private brush-label:skinny ".")
+(def ^:private brush-label:broad "o")
+(def ^:private brush-label:huge "O")
+(def ^:private brush-label:line "Y")
+
+
+;; =============================================================================
+;; State atoms (some exported for use by other modules)
+;; =============================================================================
+
+(def sketch-input-x-count*
+ "Number of input points in the sketchpad"
+ (atom 50))
+
+
+(def ^:private xs->gap
+ {200 3
+ 100 6
+ 50 12
+ 25 24
+ 20 28
+ 10 56})
+
+
+(def sketch-input-x-scale*
+ "Scale factor for x coordinates"
+ (atom (xs->gap @sketch-input-x-count*)))
+
+
+(def sketchpad-size*
+ "Current size of the sketchpad {:w width :h height}"
+ (atom {}))
+
+
+(def ^:private items-points-accessors* (atom {}))
+(def ^:private replace-drawing-widget!* (atom nil))
+(def ^:private new-xs?* (atom true))
+(def ^:private on-data-change-callback* (atom nil))
+
+(def xs*
+ "Current x coordinates when loaded from file"
+ (atom nil))
+
+
+(defn set-on-data-change-callback!
+ "Set a callback function to be called when sketchpad data changes.
+ The callback receives the current Y values as a vector."
+ [callback-fn]
+ (reset! on-data-change-callback* callback-fn))
+
+
+(defn- notify-data-change!
+ "Call the data change callback if set"
+ []
+ (when-let [callback @on-data-change-callback*]
+ (let [{:keys [items-point-getters]} @items-points-accessors*]
+ (when items-point-getters
+ (let [y-values (mapv (fn [getter]
+ (let [^Point pt (getter)]
+ (- 7.5 (/ (.getY pt)
+ (/ (:h @sketchpad-size*) 15.0)))))
+ items-point-getters)]
+ (callback y-values))))))
+
+
+;; =============================================================================
+;; Brush functions
+;; =============================================================================
+
+(defn- sketchpad-on-click:skinny-brush
+ [items x-scale ^MouseEvent e]
+ (let [{items-point-setters :items-point-setters items-point-getters :items-point-getters} @items-points-accessors*]
+ (doall
+ (map-indexed
+ (fn [i getter]
+ (let [^Point pt (getter)
+ setter (nth items-point-setters i)
+ pt-x (.getX pt)
+ pt-y (.getY pt)
+ diff (/ (abs
+ (- pt-x
+ (.getX (.getPoint e))))
+ 500.0)]
+ (setter
+ pt-x
+ (+ (* (min 1 (+ 0.95 diff)) pt-y)
+ (* (max 0 (- 0.05 diff)) (.getY (.getPoint e)))))))
+ items-point-getters))))
+
+
+(defn- sketchpad-on-click:broad-brush
+ [items x-scale ^MouseEvent e]
+ (let [{items-point-setters :items-point-setters items-point-getters :items-point-getters} @items-points-accessors*]
+ (doall
+ (map-indexed
+ (fn [i getter]
+ (let [^Point pt (getter)
+ setter (nth items-point-setters i)
+ pt-x (.getX pt)
+ pt-y (.getY pt)
+ diff (/ (abs
+ (- pt-x
+ (.getX (.getPoint e))))
+ 500.0)]
+ (setter
+ pt-x
+ (+ (* (min 1 (+ 0.85 diff)) pt-y)
+ (* (max 0 (- 0.15 diff)) (.getY (.getPoint e)))))))
+ items-point-getters))))
+
+
+(defn- sketchpad-on-click:huge-brush
+ [items x-scale ^MouseEvent e]
+ (let [{items-point-setters :items-point-setters items-point-getters :items-point-getters} @items-points-accessors*]
+ (doall
+ (map-indexed
+ (fn [i getter]
+ (let [^Point pt (getter)
+ setter (nth items-point-setters i)
+ pt-x (.getX pt)
+ pt-y (.getY pt)
+
+ diff (/ (abs
+ (- pt-x
+ (.getX (.getPoint e))))
+ 500.0)]
+ (setter
+ pt-x
+ (+ (* (min 1 (+ 0.65 diff)) pt-y)
+ (* (max 0 (- 0.35 diff)) (.getY (.getPoint e)))))))
+ items-point-getters))))
+
+
+(defn- sketchpad-on-click:line-brush
+ [items x-scale ^MouseEvent e]
+ (let [{items-point-setters :items-point-setters items-point-getters :items-point-getters} @items-points-accessors*]
+ (doall
+ (map-indexed
+ (fn [i getter]
+ (let [^Point pt (getter)
+ setter (nth items-point-setters i)
+ pt-x (.getX pt)
+ pt-y (.getY pt)]
+ (setter pt-x (.getY (.getPoint e)))))
+ items-point-getters))))
+
+
+(def ^:private brush-fn* (atom sketchpad-on-click:broad-brush))
+
+
+(def ^:private brushes-map
+ {brush-label:skinny sketchpad-on-click:skinny-brush
+ brush-label:broad sketchpad-on-click:broad-brush
+ brush-label:huge sketchpad-on-click:huge-brush
+ brush-label:line sketchpad-on-click:line-brush})
+
+
+;; =============================================================================
+;; Drawing functions
+;; =============================================================================
+
+(defn- draw-grid
+ [c ^Graphics2D g]
+ (let [w (ss/width c) h (ss/height c)]
+ (.setColor g (Color. 98 98 98))
+ (doseq [x (range 0 w 10)]
+ (.drawLine g x 0 x h))
+ (doseq [y (range 0 h 10)]
+ (.drawLine g 0 y w y)))
+ [c g])
+
+
+(defn- reposition-labels
+ [[c ^Graphics2D g]]
+ (let [{items-point-setters :items-point-setters items-point-getters :items-point-getters} @items-points-accessors*
+ w (ss/width c)
+ h (ss/height c)
+ old-w (or (:w @sketchpad-size*) w)
+ old-h (or (:h @sketchpad-size*) h)]
+
+ (reset! sketchpad-size* {:h h :w w})
+
+ ;; only on resize:
+ (when (or (true? @new-xs?*)
+ (not= w old-w)
+ (not= h old-h))
+ (reset! new-xs?* false)
+ (if-let [xs @xs*]
+ (mapv
+ (fn [i x]
+ (let [setter (nth items-point-setters i)
+ getter (nth items-point-getters i)]
+ (setter
+ (+ 50.0 (* x (/ w 675)))
+ (+ (.getY ^Point (getter))
+ (if (pos? (- h old-h))
+ (Math/ceil (/ (- h old-h) 2))
+ (Math/floor (/ (- h old-h) 2)))))))
+ (range @sketch-input-x-count*)
+ xs)
+
+ (mapv
+ (fn [i]
+ (let [setter (nth items-point-setters i)
+ getter (nth items-point-getters i)]
+ (setter
+ (+ 50.0 (* i @sketch-input-x-scale* (/ w 675)))
+ (+ (.getY ^Point (getter))
+ (if (pos? (- h old-h))
+ (Math/ceil (/ (- h old-h) 2))
+ (Math/floor (/ (- h old-h) 2)))))))
+ (range @sketch-input-x-count*))))))
+
+
+(defn- set-widget-location
+ [^JLabel widget ^double x ^double y]
+ (.setLocation widget x y))
+
+
+;; =============================================================================
+;; Widget creation
+;; =============================================================================
+
+(defn input-data-items-widget
+ "Create the sketchpad drawing widget with draggable points"
+ [points-fn]
+ (log/info "Create input-data-items-widget")
+ (let [pts (map
+ (fn [i]
+ [(+ 50.0 (* i @sketch-input-x-scale*)) (points-fn i)])
+ (range @sketch-input-x-count*))
+
+ items (map
+ (fn [pt] (ui-comp/movable (ui-comp/make-label (constantly pt) (str " ")) {:disable-x? true}))
+ pts)
+
+ items-point-getters (map
+ (fn [^JLabel widget] (fn [] (.getLocation widget)))
+ items)
+
+ items-point-setters (map
+ (fn [^JLabel widget]
+ (fn [x y]
+ (set-widget-location widget x y)))
+ items)
+
+ ^JPanel drawing-widget (ss/xyz-panel
+ :paint (comp reposition-labels draw-grid)
+ :id :xyz
+ :items items
+ :listen [:mouse-clicked (fn [e]
+ (@brush-fn* items @sketch-input-x-scale* e)
+ (notify-data-change!))])]
+
+ (.setCursor drawing-widget (Cursor/getPredefinedCursor Cursor/HAND_CURSOR))
+ (log/info "Set hand cursor for sketchpad widget: " (.getCursor drawing-widget))
+
+ (reset! items-points-accessors* {:drawing-widget drawing-widget
+ :items-point-getters items-point-getters
+ :items-point-setters items-point-setters})
+
+ {:drawing-widget drawing-widget
+ :items-point-getters items-point-getters
+ :items-point-setters items-point-setters}))
+
+
+(defn get-items-points-accessors
+ "Get the current items-points-accessors atom value"
+ []
+ @items-points-accessors*)
+
+
+(defn getters->input-data
+ "Convert point getters to input data coordinates"
+ [items-point-getters]
+ (mapv (fn [getter]
+ (let [^Point pt (getter)]
+ [(/ (- (.getX pt) 50.0) (/ (:w @sketchpad-size*) 20.0))
+ (- 7.5 (/ (.getY pt)
+ (/ (:h @sketchpad-size*) 15.0)))]))
+ items-point-getters))
+
+
+(defn set-replace-drawing-widget-fn!
+ "Set the function used to replace the drawing widget"
+ [f]
+ (reset! replace-drawing-widget!* f))
+
+
+(defn init-drag-callback!
+ "Initialize the drag finish callback to notify data changes"
+ []
+ (ui-comp/set-on-drag-finish-callback! notify-data-change!))
+
+
+(defn redraw-sketch-widget!
+ "Redraw the sketchpad widget"
+ []
+ (@replace-drawing-widget!* (:drawing-widget @items-points-accessors*)))
+
+
+;; =============================================================================
+;; Control panels
+;; =============================================================================
+
+(defn- brush-on-change
+ [^MouseEvent e]
+ (let [b (.getText ^JRadioButtonMenuItem (.getSource e))]
+ (reset! brush-fn* (brushes-map b))
+ (log/info "brush change to " b)))
+
+
+(defn- xs-on-change
+ [^MouseEvent e]
+ (let [xs-str (.getText ^JRadioButtonMenuItem (.getSource e))
+ new-xs (Integer/parseInt xs-str)]
+ (reset! xs* nil)
+ (reset! sketch-input-x-count* new-xs)
+ (reset! sketch-input-x-scale* (xs->gap new-xs))
+ (redraw-sketch-widget!)
+ (log/info "brush xs to " xs-str " -> " new-xs)))
+
+
+(defn ^JPanel brush-panel
+ "Create the brush selection panel"
+ []
+ (let [brush-config-container (ui-comp/panel-grid {:rows 1 :cols 4 :border (ui-comp/radio-controls-border "Brush")})
+ ^JPanel brush-container (ui-comp/panel-grid {:rows 1 :cols 3})
+
+ btn-group-brush (ss/button-group)
+ ^JRadioButtonMenuItem b-radio-0 (ss/radio-menu-item
+ :text brush-label:skinny
+ :group btn-group-brush
+ :listen [:mouse-clicked brush-on-change])
+ ^JRadioButtonMenuItem b-radio-1 (ss/radio-menu-item
+ :selected? true
+ :text brush-label:broad
+ :group btn-group-brush
+ :listen [:mouse-clicked brush-on-change])
+ ^JRadioButtonMenuItem b-radio-2 (ss/radio-menu-item
+ :text brush-label:huge
+ :group btn-group-brush
+ :listen [:mouse-clicked brush-on-change])
+ ^JRadioButtonMenuItem b-radio-3 (ss/radio-menu-item
+ :text brush-label:line
+ :group btn-group-brush
+ :listen [:mouse-clicked brush-on-change])]
+ (.add brush-config-container b-radio-0)
+ (.add brush-config-container b-radio-1)
+ (.add brush-config-container b-radio-2)
+ (.add brush-config-container b-radio-3)
+ (.add brush-container brush-config-container)
+ brush-container))
+
+
+(defn ^JPanel xs-panel
+ "Create the points count selection panel"
+ []
+ (let [xs-config-container (ui-comp/panel-grid {:rows 1 :cols 5 :border (ui-comp/radio-controls-border "Points Count")})
+ ^JPanel xs-container (ui-comp/panel-grid {:rows 1 :cols 1})
+
+ btn-group-xs (ss/button-group)
+ ^JRadioButtonMenuItem xs-radio-10 (ss/radio-menu-item
+ :text "10"
+ :group btn-group-xs
+ :listen [:mouse-clicked xs-on-change])
+ ^JRadioButtonMenuItem xs-radio-25 (ss/radio-menu-item
+ :text "25"
+ :group btn-group-xs
+ :listen [:mouse-clicked xs-on-change])
+ ^JRadioButtonMenuItem xs-radio-50 (ss/radio-menu-item
+ :selected? true
+ :text "50"
+ :group btn-group-xs
+ :listen [:mouse-clicked xs-on-change])
+ ^JRadioButtonMenuItem xs-radio-100 (ss/radio-menu-item
+ :text "100"
+ :group btn-group-xs
+ :listen [:mouse-clicked xs-on-change])
+
+ ^JRadioButtonMenuItem xs-radio-200 (ss/radio-menu-item
+ :text "200"
+ :group btn-group-xs
+ :listen [:mouse-clicked xs-on-change])]
+ (.add xs-config-container xs-radio-10)
+ (.add xs-config-container xs-radio-25)
+ (.add xs-config-container xs-radio-50)
+ (.add xs-config-container xs-radio-100)
+ ;; (.add xs-config-container xs-radio-200)
+ (.add xs-container xs-config-container)
+ xs-container))
+
+
+(defn update-points-from-setters!
+ "Update points using setters with values from a function"
+ [new-fn]
+ (let [{:keys [items-point-setters items-point-getters]} @items-points-accessors*]
+ (doseq [i (range @sketch-input-x-count*)]
+ ((nth items-point-setters i)
+ (.getX ^Point ((nth items-point-getters i)))
+ (new-fn i)))))
+
+
+(defn repaint-drawing-widget!
+ "Repaint the drawing widget"
+ []
+ (when-let [drawing-widget (:drawing-widget @items-points-accessors*)]
+ (ss/repaint! drawing-widget)))
+
+
+(defn set-xs-count!
+ "Set the number of x points and trigger redraw"
+ [n]
+ (reset! sketch-input-x-count* n)
+ (reset! new-xs?* true))
diff --git a/src/closyr/ui/theme.clj b/src/closyr/ui/theme.clj
new file mode 100644
index 00000000..049e30dd
--- /dev/null
+++ b/src/closyr/ui/theme.clj
@@ -0,0 +1,55 @@
+(ns closyr.ui.theme
+ "UI theme setup and font utilities"
+ (:require
+ [clojure.java.io :as io]
+ [closyr.util.log :as log])
+ (:import
+ (io.materialtheme.darkstackoverflow
+ DarkStackOverflowTheme)
+ (java.awt
+ Font
+ GraphicsEnvironment
+ Image
+ Toolkit)
+ (javax.swing
+ JFrame
+ UIManager
+ UnsupportedLookAndFeelException)
+ (mdlaf
+ MaterialLookAndFeel)))
+
+
+(set! *warn-on-reflection* true)
+
+
+(defn setup-theme
+ "Initialize the Material Design dark theme for the application"
+ []
+ (try
+ (UIManager/setLookAndFeel
+ (MaterialLookAndFeel.
+ (DarkStackOverflowTheme.)))
+
+ (catch UnsupportedLookAndFeelException e
+ (log/error "Theme error: " e))))
+
+
+(defn find-unicode-font
+ "Find a font that supports Unicode math symbols. Returns a Font or nil."
+ [size]
+ (let [preferred-fonts ["DejaVu Sans" "Noto Sans" "Segoe UI Symbol"
+ "Arial Unicode MS" "Lucida Sans Unicode"
+ "FreeSans" "Liberation Sans"]
+ available-fonts (set (.getAvailableFontFamilyNames
+ (GraphicsEnvironment/getLocalGraphicsEnvironment)))
+ found-font (first (filter available-fonts preferred-fonts))]
+ (when found-font
+ (Font. found-font Font/PLAIN size))))
+
+
+(defn set-app-icon
+ "Set the application window icon"
+ [^JFrame frame]
+ (let [^Image icon (.getImage (Toolkit/getDefaultToolkit) (io/resource "icons/icon_v5_qtr.png"))]
+ (doto frame
+ (.setIconImage icon))))
diff --git a/src/closyr/util/csv.clj b/src/closyr/util/csv.clj
index 445071d3..8e515c13 100644
--- a/src/closyr/util/csv.clj
+++ b/src/closyr/util/csv.clj
@@ -14,10 +14,10 @@
data-content (map (fn [vs] (map #(Double/parseDouble %) vs))
(if has-col-names
(do
- (log/info "Got CSV with column names " (first csv-data))
+ (log/debug "Got CSV with column names " (first csv-data))
(rest csv-data))
(do
- (log/info "Got CSV without column names " (first csv-data))
+ (log/debug "Got CSV without column names " (first csv-data))
csv-data)))
col-names (if has-col-names
(->> (first csv-data)
@@ -25,7 +25,7 @@
repeat)
(repeat [:x :y]))]
(when-not (= #{:x :y} (set (first col-names))) (throw (Exception. "Need x/y columns")))
- (log/info "Data content:" (count data-content) (first col-names) data-content)
+ (log/debug "Data content:" (count data-content) (first col-names) data-content)
(map zipmap col-names data-content)))
diff --git a/src/closyr/util/prng.clj b/src/closyr/util/prng.clj
index b1371980..430b087b 100644
--- a/src/closyr/util/prng.clj
+++ b/src/closyr/util/prng.clj
@@ -1,5 +1,5 @@
(ns closyr.util.prng
- (:refer-clojure :exclude [rand rand-int rand-nth shuffle])
+ (:refer-clojure :exclude [rand rand-int rand-nth shuffle random-uuid])
(:import
(clojure.lang
RT)
@@ -7,7 +7,8 @@
ArrayList
Collection
Collections
- Random)))
+ Random
+ UUID)))
(set! *warn-on-reflection* true)
@@ -56,5 +57,37 @@
(RT/vector (.toArray al))))
-(set-random-seed! 888)
-(rand-int 100)
+(defn shuffle-arraylist!
+ "Shuffle a collection in-place and return as ArrayList for efficient iteration.
+ Avoids vector conversion overhead when the result will be iterated sequentially."
+ ^ArrayList [^Collection coll]
+ (let [^ArrayList al (if (instance? ArrayList coll) coll (ArrayList. coll))]
+ (Collections/shuffle al rng)
+ al))
+
+
+(defn random-uuid
+ "Generate a random UUID using the seeded PRNG.
+ This produces deterministic UUIDs when the seed is set."
+ []
+ (let [bytes (byte-array 16)]
+ (.nextBytes rng bytes)
+ ;; Set version to 4 (random) and variant to IETF
+ ;; Use unchecked-byte to handle values > 127
+ (aset bytes 6 (unchecked-byte (bit-or (bit-and (aget bytes 6) 0x0f) 0x40)))
+ (aset bytes 8 (unchecked-byte (bit-or (bit-and (aget bytes 8) 0x3f) 0x80)))
+ ;; Convert bytes to UUID
+ (let [msb (reduce (fn [acc i]
+ (bit-or (bit-shift-left acc 8)
+ (bit-and (aget bytes i) 0xff)))
+ 0 (range 8))
+ lsb (reduce (fn [acc i]
+ (bit-or (bit-shift-left acc 8)
+ (bit-and (aget bytes i) 0xff)))
+ 0 (range 8 16))]
+ (UUID. msb lsb))))
+
+
+(comment
+ (set-random-seed! 888)
+ (rand-int 100))
diff --git a/src/closyr/util/spec.clj b/src/closyr/util/spec.clj
index 346ada8e..e5536eee 100644
--- a/src/closyr/util/spec.clj
+++ b/src/closyr/util/spec.clj
@@ -32,8 +32,8 @@
[n s o]
(when (and *check-schema* (not (m/validate s o)))
(let [explained (me/humanize (m/explain s o))]
- (log/error "Error in input schema: " n)
- (pp/pprint [n explained])
+ (log/error "Error in input schema: " n explained)
+ ;(pp/pprint [n explained])
(throw (Exception. (str "Error, input failed schema: " [n explained])))))
true)
@@ -144,6 +144,17 @@
(def ^:private PopulationCount
[:int {:min 1 :max 100000}])
+(def ^:private RandomSeed
+ [:int {:min Integer/MIN_VALUE :max Integer/MAX_VALUE}])
+
+
+(def ^:private ScoringMethod
+ [:enum :mae-max :log-cosh :r-squared])
+
+
+(def ^:private SimplicityBias
+ [:enum :none :tiebreaker :light :strong])
+
(def ^:private GAPhenotype
[:map
@@ -189,10 +200,17 @@
[:use-gui? :boolean]
[:max-leafs #'MaxLeafs]
[:input-phenos-count {:optional true} #'PopulationCount]
- [:log-steps pos-int?]
+ [:random-seed {:optional true} [:maybe #'RandomSeed]]
+ [:log-steps {:optional true} [:maybe pos-int?]]
[:use-flamechart [:maybe :boolean]]
[:input-xs-exprs [:vector #'SymbolicExpr]]
- [:input-ys-exprs [:vector #'SymbolicExpr]]])
+ [:input-ys-exprs [:vector #'SymbolicExpr]]
+ [:progress-callback {:optional true} [:maybe fn?]]
+ [:adaptive-mode {:optional true} [:maybe :boolean]]
+ [:quiet-logs {:optional true} [:maybe :boolean]]
+ [:use-eval-cache {:optional true} [:maybe :boolean]]
+ [:scoring-method {:optional true} [:maybe #'ScoringMethod]]
+ [:simplicity-bias {:optional true} [:maybe #'SimplicityBias]]])
(def ^:private ExtendedDomainArgs
@@ -215,10 +233,20 @@
[:input-xs-count #'PointsCount]
[:input-xs-vec #'NumberVector]
[:input-ys-vec #'NumberVector]
+ [:input-ys-arr {:optional true} some?]
[:input-iters #'Iterations]
[:initial-phenos [:maybe #'GAPopulationPhenotypes]]
[:input-phenos-count [:maybe #'PopulationCount]]
- [:max-leafs [:maybe #'MaxLeafs]]])
+ [:random-seed {:optional true} [:maybe #'RandomSeed]]
+ [:max-leafs [:maybe #'MaxLeafs]]
+ [:progress-callback {:optional true} [:maybe fn?]]
+ [:mutations-blacklist {:optional true} [:maybe [:vector string?]]]
+ [:log-steps {:optional true} [:maybe pos-int?]]
+ [:adaptive-mode {:optional true} [:maybe :boolean]]
+ [:quiet-logs {:optional true} [:maybe :boolean]]
+ [:use-eval-cache {:optional true} [:maybe :boolean]]
+ [:scoring-method {:optional true} [:maybe #'ScoringMethod]]
+ [:simplicity-bias {:optional true} [:maybe #'SimplicityBias]]])
(def ^:private SolverEvalArgs
@@ -232,6 +260,7 @@
[:map
{:closed false}
[:input-ys-vec #'NumberVector]
+ [:input-ys-arr {:optional true} some?]
[:input-xs-list #'PrimitiveArrayOfIExpr]
[:input-xs-count #'PointsCount]])
@@ -262,7 +291,15 @@
[:input-ys-vec #'NumberVector]
[:input-iters #'Iterations]
[:input-phenos-count #'PopulationCount]
- [:max-leafs [:maybe #'MaxLeafs]]])
+ [:random-seed {:optional true} [:maybe #'RandomSeed]]
+ [:max-leafs [:maybe #'MaxLeafs]]
+ [:mutations-blacklist {:optional true} [:maybe [:vector string?]]]
+ [:log-steps {:optional true} [:maybe pos-int?]]
+ [:adaptive-mode {:optional true} [:maybe :boolean]]
+ [:quiet-logs {:optional true} [:maybe :boolean]]
+ [:use-eval-cache {:optional true} [:maybe :boolean]]
+ [:scoring-method {:optional true} [:maybe #'ScoringMethod]]
+ [:simplicity-bias {:optional true} [:maybe #'SimplicityBias]]])
(def ^:private SolverInputArgs
@@ -279,7 +316,16 @@
[:input-iters {:optional true} #'Iterations]
[:iters {:optional true} #'Iterations]
[:input-phenos-count {:optional true} #'PopulationCount]
- [:max-leafs {:optional true} [:maybe #'MaxLeafs]]])
+ [:random-seed {:optional true} [:maybe #'RandomSeed]]
+ [:max-leafs {:optional true} [:maybe #'MaxLeafs]]
+ [:mutations-blacklist {:optional true} [:maybe [:vector string?]]]
+ [:log-steps {:optional true} [:maybe pos-int?]]
+ [:progress-callback {:optional true} [:maybe fn?]]
+ [:adaptive-mode {:optional true} [:maybe :boolean]]
+ [:quiet-logs {:optional true} [:maybe :boolean]]
+ [:use-eval-cache {:optional true} [:maybe :boolean]]
+ [:scoring-method {:optional true} [:maybe #'ScoringMethod]]
+ [:simplicity-bias {:optional true} [:maybe #'SimplicityBias]]])
(def ^:private SolverGUIMessage
@@ -290,7 +336,15 @@
[:input-data-y #'NumberVector]
[:input-iters #'Iterations]
[:input-phenos-count #'PopulationCount]
- [:max-leafs {:optional true} [:maybe #'MaxLeafs]]])
+ [:random-seed {:optional true} [:maybe #'RandomSeed]]
+ [:max-leafs {:optional true} [:maybe #'MaxLeafs]]
+ [:mutations-blacklist {:optional true} [:maybe [:vector string?]]]
+ [:log-steps {:optional true} [:maybe pos-int?]]
+ [:adaptive-mode {:optional true} [:maybe :boolean]]
+ [:quiet-logs {:optional true} [:maybe :boolean]]
+ [:use-eval-cache {:optional true} [:maybe :boolean]]
+ [:scoring-method {:optional true} [:maybe #'ScoringMethod]]
+ [:simplicity-bias {:optional true} [:maybe #'SimplicityBias]]])
(def ^:private CLIArgs
@@ -303,7 +357,15 @@
[:xs {:optional true} [:maybe #'NumberVector]]
[:ys {:optional true} [:maybe #'NumberVector]]
[:use-flamechart {:optional true} boolean?]
- [:max-leafs {:optional true} #'MaxLeafs]])
+ [:max-leafs {:optional true} #'MaxLeafs]
+ [:seed {:optional true} [:maybe #'RandomSeed]]
+ [:mutations-whitelist {:optional true} [:maybe [:vector string?]]]
+ [:mutations-blacklist {:optional true} [:maybe [:vector string?]]]
+ [:adaptive-mode {:optional true} [:maybe boolean?]]
+ [:quiet-logs {:optional true} [:maybe boolean?]]
+ [:use-eval-cache {:optional true} [:maybe boolean?]]
+ [:scoring-method {:optional true} [:maybe #'ScoringMethod]]
+ [:simplicity-bias {:optional true} [:maybe #'SimplicityBias]]])
(def ^:private ModificationsResult
diff --git a/src/closyr/web/handlers/api.clj b/src/closyr/web/handlers/api.clj
new file mode 100644
index 00000000..61ddaaf5
--- /dev/null
+++ b/src/closyr/web/handlers/api.clj
@@ -0,0 +1,789 @@
+(ns closyr.web.handlers.api
+ "JSON API handlers for the solver."
+ (:require
+ [cheshire.core :as json]
+ [clojure.core.async :as async]
+ [clojure.data.csv :as csv]
+ [clojure.java.io :as io]
+ [clojure.stacktrace :as st]
+ [closyr.ga :as ga]
+ [closyr.ops :as ops]
+ [closyr.ops.common :as ops-common]
+ [closyr.ops.initialize :as ops-init]
+ [closyr.symbolic-regression :as symreg]
+ [closyr.util.log :as log]
+ [closyr.util.prng :as prng]
+ [closyr.web.sse :as sse])
+ (:import
+ (java.io StringReader)
+ (java.util Date)
+ (java.util.concurrent Future)
+ (org.matheclipse.core.eval.exception TimeoutException)
+ (org.matheclipse.core.interfaces IExpr)))
+
+
+(set! *warn-on-reflection* true)
+
+
+;; Job storage - maps job-id to job state
+(defonce jobs* (atom {}))
+
+
+(defn- generate-job-id
+ []
+ (str (java.util.UUID/randomUUID)))
+
+
+(defn- parse-doubles
+ "Parse a string or vector of numbers into a vector of doubles."
+ [input]
+ (cond
+ (vector? input) (mapv double input)
+ (string? input) (->> (clojure.string/split input #"[,\s]+")
+ (filter seq)
+ (mapv #(Double/parseDouble (clojure.string/trim %))))
+ :else (throw (ex-info "Invalid input format" {:input input}))))
+
+
+(defn- phenotype->solution
+ "Convert a phenotype to a solution map for JSON serialization.
+ If run-args and run-config are provided, computes scores for all scoring methods
+ including raw scores (without length deduction) for fair cross-job comparison."
+ ([{:keys [^IExpr expr score] :as pheno}]
+ (when (and expr score)
+ {:formula (str expr)
+ :score score
+ :leafCount (.leafCount expr)}))
+ ([{:keys [^IExpr expr score] :as pheno} run-args run-config]
+ (when (and expr score)
+ (let [{:keys [scores raw-scores length-deductions]}
+ (ops/compute-all-method-scores-detailed run-args run-config pheno)]
+ {:formula (str expr)
+ :score score
+ :leafCount (.leafCount expr)
+ :scores scores
+ :rawScores raw-scores
+ :lengthDeductions length-deductions}))))
+
+
+(defn- job-stopped?
+ "Check if a job has been requested to stop."
+ [job-id]
+ (get-in @jobs* [job-id :stop-requested]))
+
+
+(defn- job-paused?
+ "Check if a job is paused."
+ [job-id]
+ (get-in @jobs* [job-id :paused]))
+
+
+(defn- wait-while-paused!
+ "Block while job is paused. Returns true if should continue, false if stopped.
+ Uses single atom reads to get consistent snapshots and avoid race conditions."
+ [job-id]
+ (loop []
+ ;; Single atomic read to get consistent view of job state
+ (let [{:keys [stop-requested paused]} (get @jobs* job-id)]
+ (cond
+ ;; Always check stop first - if stop requested, exit immediately
+ stop-requested false
+
+ ;; If paused, sleep and check again
+ paused
+ (do
+ (try
+ (Thread/sleep 100)
+ (catch InterruptedException _ nil))
+ ;; After waking, do another atomic read to check stop
+ (if (:stop-requested (get @jobs* job-id))
+ false
+ (recur)))
+
+ ;; Not stopped and not paused - continue execution
+ :else true))))
+
+
+(defn- interrupted-exception?
+ "Check if an exception or any of its causes is an InterruptedException or TimeoutException."
+ [^Throwable e]
+ (loop [ex e]
+ (cond
+ (nil? ex) false
+ (instance? InterruptedException ex) true
+ (instance? org.matheclipse.core.eval.exception.TimeoutException ex) true
+ :else (recur (.getCause ex)))))
+
+
+(defn- cancel-future-with-interrupt!
+ "Cancel a future and interrupt its thread if running.
+ Unlike future-cancel which uses .cancel(false), this uses .cancel(true)
+ to actually interrupt the running thread."
+ [^Future f]
+ (when f
+ (try
+ (.cancel f true) ; true = interrupt if running
+ (catch Exception e
+ (log/warn "Error cancelling future:" (.getMessage e))))))
+
+
+(defn- run-solver-job!
+ "Run the solver in a background thread and update job state.
+ Returns the future so it can be cancelled.
+
+ Config options:
+ - :iterations - number of iterations (default 10)
+ - :population - population size (default 20)
+ - :maxLeafs - max leaf count (default 40)
+ - :seed - random seed for reproducibility
+ - :mutationsBlacklist - mutations to exclude
+ - :adaptiveMode - use adaptive mutation rates
+ - :quietLogs - reduce logging verbosity
+ - :useEvalCache - cache evaluation results
+ - :scoringMethod - scoring method (mae-max, log-cosh, r-squared)
+ - :seedFormulas - vector of formula strings to seed population
+ - :freshPercent - percentage of fresh phenotypes (default 0.2)"
+ [job-id {:keys [xs ys config sse-channel]}]
+ (let [job-future
+ (future
+ (try
+ ;; Reset all global state before each run to ensure clean state
+ (reset! ops/test-timer* (Date.))
+ (reset! ops-common/do-not-simplify-fns* {})
+
+ (let [xs-vec (parse-doubles xs)
+ ys-vec (parse-doubles ys)
+ iterations (get config :iterations 10)
+ population-size (get config :population 20)
+ max-leafs (get config :maxLeafs 40)
+ random-seed (get config :seed)
+ mutations-blacklist (get config :mutationsBlacklist)
+ adaptive-mode (get config :adaptiveMode false)
+ quiet-logs (get config :quietLogs true)
+ use-eval-cache (get config :useEvalCache false)
+ scoring-method (keyword (get config :scoringMethod "mae-max"))
+ simplicity-bias (keyword (get config :simplicityBias "tiebreaker"))
+ seed-formulas (get config :seedFormulas)
+ fresh-percent (get config :freshPercent 0.2)
+
+ ;; Progress callback that sends SSE events and checks for stop/pause
+ progress-callback (fn [progress-data]
+ ;; Check if stop was requested
+ (when (job-stopped? job-id)
+ (throw (ex-info "Job stopped by user" {:type :stopped})))
+ ;; Wait while paused (returns false if stopped during pause)
+ (when-not (wait-while-paused! job-id)
+ (throw (ex-info "Job stopped by user" {:type :stopped})))
+ (when sse-channel
+ ((:send! sse-channel) "progress" progress-data))
+ ;; Also update job state
+ (swap! jobs* assoc-in [job-id :progress] progress-data))
+
+ ;; Apply mutations blacklist if provided
+ initial-muts (if (seq mutations-blacklist)
+ (ops-init/filter-mutations {:blacklist mutations-blacklist})
+ (ops-init/initial-mutations))
+
+ ;; Create initial phenotypes - either seeded from formulas or fresh
+ initial-phenos (if (seq seed-formulas)
+ (do
+ (log/info "Seeding population from" (count seed-formulas) "formulas,"
+ "fresh percent:" fresh-percent)
+ (ops-init/seeded-phenotypes seed-formulas fresh-percent population-size))
+ (ops-init/initial-phenotypes population-size))
+
+ run-config {:initial-phenos initial-phenos
+ :initial-muts initial-muts
+ :iters iterations
+ :use-gui? false
+ :use-flamechart false
+ :max-leafs max-leafs
+ :random-seed random-seed
+ :adaptive-mode adaptive-mode
+ :quiet-logs quiet-logs
+ :use-eval-cache use-eval-cache
+ :scoring-method scoring-method
+ :simplicity-bias simplicity-bias
+ :progress-callback progress-callback
+ :input-xs-exprs (ops-common/doubles->exprs xs-vec)
+ :input-ys-exprs (ops-common/doubles->exprs ys-vec)}
+
+ _ (do (log/info "Starting job" job-id "- iterations:" iterations "population:" population-size
+ "points:" (count xs-vec) "adaptive:" adaptive-mode "quiet-logs:" quiet-logs
+ "scoring-method:" scoring-method "simplicity-bias:" simplicity-bias
+ "random-seed:" random-seed "use-eval-cache:" use-eval-cache)
+ (swap! jobs* assoc-in [job-id :status] :running))
+ result (symreg/run-find-formula run-config)
+
+ ;; Create run-args for computing all scores on final solutions
+ score-run-args {:input-xs-list (ops-common/exprs->exprs-list
+ (ops-common/doubles->exprs xs-vec))
+ :input-xs-count (count xs-vec)
+ :input-ys-vec ys-vec
+ :input-ys-arr (double-array ys-vec)}
+ score-run-config {:max-leafs max-leafs}
+
+ ;; Extract unique solutions from final population (deduplicated by formula)
+ ;; Compute all scoring method scores for each solution
+ ;; Bind simplicity-bias to ensure consistent score computation with evolution
+ solutions (binding [ops/*simplicity-bias* simplicity-bias]
+ (->> (get-in result [:final-population :pop])
+ (filter (fn [p] (and (:score p) (:expr p))))
+ (sort-by :score)
+ reverse
+ (mapv #(phenotype->solution % score-run-args score-run-config))
+ (filterv some?)
+ ;; Deduplicate by formula, keeping first (best score)
+ (reduce (fn [[seen results] sol]
+ (if (seen (:formula sol))
+ [seen results]
+ [(conj seen (:formula sol)) (conj results sol)]))
+ [#{} []])
+ second
+ (take 10)
+ vec))
+
+ source-job (get-in @jobs* [job-id :source-job])
+ final-result {:iterations-done (:iters-done result)
+ :best-solution (first solutions)
+ :scoring-method scoring-method
+ :simplicity-bias simplicity-bias
+ :all-solutions solutions
+ :source-job source-job}]
+
+ ;; Update job with final result and log completion (preserve source-job)
+ (log/info "Job" job-id "completed. Best formula:" (:formula (first solutions))
+ "Score:" (:score (first solutions)))
+ (swap! jobs* update job-id merge {:status :completed
+ :result final-result})
+
+ ;; Send completion event via SSE
+ (when sse-channel
+ ((:send! sse-channel) "complete" final-result)
+ ((:close! sse-channel))))
+
+ (catch Exception e
+ (let [;; Check if this is a stop - either our flag, InterruptedException, or stop-requested
+ stopped? (or (= :stopped (:type (ex-data e)))
+ (interrupted-exception? e)
+ (job-stopped? job-id))]
+ (if stopped?
+ (let [job-state (get @jobs* job-id)
+ progress (:progress job-state)
+ source-job (:source-job job-state)]
+ (log/info "Solver job stopped by user:" job-id)
+ (swap! jobs* update job-id merge {:status :stopped})
+ (when (and sse-channel (:send! sse-channel))
+ (try
+ ((:send! sse-channel) "stopped" (cond-> {:message "Job stopped by user"}
+ progress (assoc :last-progress progress)
+ source-job (assoc :source-job source-job)))
+ ((:close! sse-channel))
+ (catch Exception _ nil))))
+ (do
+ (log/error "Solver job failed:" (.getMessage e))
+ (log/error "Stack trace:" (with-out-str (st/print-stack-trace e)))
+ (swap! jobs* assoc job-id {:status :failed
+ :error (.getMessage e)})
+ (when (and sse-channel (:send! sse-channel))
+ (try
+ ((:send! sse-channel) "error" {:error (.getMessage e)})
+ ((:close! sse-channel))
+ (catch Exception _ nil)))))))))]
+ ;; Store the future in the job state
+ (swap! jobs* assoc-in [job-id :future] job-future)
+ job-future))
+
+
+;; ============================================================================
+;; API Handlers
+;; ============================================================================
+
+(defn solve
+ "POST /api/solve - Start a new solver job.
+ Body: {:xs [1,2,3], :ys [1,4,9], :config {:iterations 10, :population 20}}"
+ [{:keys [body-params]}]
+ (try
+ (let [{:keys [xs ys config]} body-params
+ _ (when (or (nil? xs) (nil? ys))
+ (throw (ex-info "xs and ys are required" {})))
+ job-id (generate-job-id)
+ sse-channel (sse/create-event-channel)]
+
+ ;; Initialize job state with SSE channel
+ (swap! jobs* assoc job-id {:status :pending
+ :progress nil
+ :result nil
+ :sse-channel sse-channel})
+
+ ;; Start solver in background
+ (run-solver-job! job-id {:xs xs
+ :ys ys
+ :config config
+ :sse-channel sse-channel})
+
+ {:status 202
+ :headers {"Content-Type" "application/json"}
+ :body (json/encode {:jobId job-id
+ :eventsUrl (str "/api/jobs/" job-id "/events")})})
+
+ (catch Exception e
+ {:status 400
+ :headers {"Content-Type" "application/json"}
+ :body (json/encode {:error (.getMessage e)})})))
+
+
+(defn get-job
+ "GET /api/jobs/:id - Get job status and result."
+ [{:keys [path-params]}]
+ (let [job-id (:id path-params)
+ job (get @jobs* job-id)]
+ (if job
+ {:status 200
+ :headers {"Content-Type" "application/json"}
+ :body (json/encode (dissoc job :sse-channel :future))}
+ {:status 404
+ :headers {"Content-Type" "application/json"}
+ :body (json/encode {:error "Job not found"})})))
+
+
+(defn stop-job
+ "POST /api/jobs/:id/stop - Stop a running job."
+ [{:keys [path-params]}]
+ (let [job-id (:id path-params)
+ job (get @jobs* job-id)]
+ (if job
+ (if (= :running (:status job))
+ (let [sse-channel (:sse-channel job)
+ progress (:progress job)]
+ ;; Set the stop flag - do NOT clear :paused to avoid race condition in wait-while-paused!
+ ;; The pause loop checks job-stopped? first, so it will properly detect the stop.
+ (swap! jobs* update job-id merge {:stop-requested true :status :stopped})
+ ;; Send stopped event immediately via SSE, including last progress data
+ (when (and sse-channel (:send! sse-channel))
+ (try
+ ((:send! sse-channel) "stopped" (merge {:message "Job stopped by user"}
+ (when progress
+ {:last-progress progress})))
+ ((:close! sse-channel))
+ (catch Exception _ nil)))
+ ;; Cancel the future with interrupt to actually stop the thread
+ (when-let [f (:future job)]
+ (cancel-future-with-interrupt! f))
+ {:status 200
+ :headers {"Content-Type" "application/json"}
+ :body (json/encode {:message "Stop requested" :jobId job-id})})
+ {:status 400
+ :headers {"Content-Type" "application/json"}
+ :body (json/encode {:error (str "Job is not running, status: " (name (:status job)))})})
+ {:status 404
+ :headers {"Content-Type" "application/json"}
+ :body (json/encode {:error "Job not found"})})))
+
+
+(defn pause-job
+ "POST /api/jobs/:id/pause - Pause a running job."
+ [{:keys [path-params]}]
+ (let [job-id (:id path-params)
+ job (get @jobs* job-id)]
+ (if job
+ (if (= :running (:status job))
+ (do
+ (swap! jobs* assoc-in [job-id :paused] true)
+ {:status 200
+ :headers {"Content-Type" "application/json"}
+ :body (json/encode {:message "Job paused" :jobId job-id})})
+ {:status 400
+ :headers {"Content-Type" "application/json"}
+ :body (json/encode {:error (str "Job is not running, status: " (name (:status job)))})})
+ {:status 404
+ :headers {"Content-Type" "application/json"}
+ :body (json/encode {:error "Job not found"})})))
+
+
+(defn resume-job
+ "POST /api/jobs/:id/resume - Resume a paused job."
+ [{:keys [path-params]}]
+ (let [job-id (:id path-params)
+ job (get @jobs* job-id)]
+ (if job
+ (if (and (= :running (:status job)) (:paused job))
+ (do
+ (swap! jobs* assoc-in [job-id :paused] false)
+ {:status 200
+ :headers {"Content-Type" "application/json"}
+ :body (json/encode {:message "Job resumed" :jobId job-id})})
+ {:status 400
+ :headers {"Content-Type" "application/json"}
+ :body (json/encode {:error "Job is not paused"})})
+ {:status 404
+ :headers {"Content-Type" "application/json"}
+ :body (json/encode {:error "Job not found"})})))
+
+
+(defn- valid-seed-formula?
+ "Check if a formula string is valid for seeding.
+ Rejects formulas that look corrupted or contain problematic patterns."
+ [formula]
+ (and (string? formula)
+ (not (clojure.string/blank? formula))
+ ;; Reject Symja internal wrapper patterns
+ (not (.contains ^String formula "Hold("))
+ (not (.contains ^String formula "Function("))
+ (not (.contains ^String formula "{x}"))
+ ;; Reject truncated string markers
+ (not (.contains ^String formula "<<"))
+ ;; Reject list patterns that shouldn't be in formulas
+ (not (.contains ^String formula "List("))
+ ;; Reject excessively long formulas (likely corrupted)
+ (< (count formula) 500)))
+
+
+(defn continue-job
+ "POST /api/jobs/:id/continue - Continue evolution using results from a completed/stopped job.
+
+ Body: {:xs [...], :ys [...], :config {...}}
+ - xs/ys: new data points (optional, will use original job's data if not provided)
+ - config: new configuration (optional, merges with original job's config)
+ - :freshPercent - percentage of fresh phenotypes (default 0.2 = 20% fresh, 80% seeded)
+
+ Uses formulas from the original job's results to seed the new population."
+ [{:keys [path-params body-params]}]
+ (let [source-job-id (:id path-params)
+ source-job (get @jobs* source-job-id)]
+ (if source-job
+ (if (#{:completed :stopped} (:status source-job))
+ (try
+ (let [;; Get formulas from source job
+ result (:result source-job)
+ all-solutions (or (:all-solutions result) [])
+ progress (:progress source-job)
+ ;; For stopped jobs, best-formula may be in progress
+ raw-formulas (cond
+ ;; Completed job: use all-solutions
+ (seq all-solutions)
+ (mapv :formula all-solutions)
+ ;; Stopped job: use best formula from progress
+ (:best-formula progress)
+ [(:best-formula progress)]
+ :else
+ [])
+ ;; Filter out invalid/corrupted formulas
+ seed-formulas (filterv valid-seed-formula? raw-formulas)
+ rejected-count (- (count raw-formulas) (count seed-formulas))
+ _ (when (pos? rejected-count)
+ (log/info "Filtered out" rejected-count "invalid formulas from" (count raw-formulas) "total"))
+
+ _ (when (empty? seed-formulas)
+ (if (seq raw-formulas)
+ (do
+ (log/error "All formulas were rejected as invalid:" raw-formulas)
+ (throw (ex-info "All formulas were invalid for seeding" {:raw-formulas raw-formulas})))
+ (throw (ex-info "No formulas available to seed from" {}))))
+
+ ;; Get config from body or use source job's config (stored in result)
+ body-config (or (:config body-params) {})
+ fresh-percent (get body-config :freshPercent 0.2)
+
+ ;; Merge seedFormulas into config
+ config (assoc body-config :seedFormulas seed-formulas
+ :freshPercent fresh-percent)
+
+ ;; Get xs/ys - use from body if provided, otherwise... we need to store them
+ ;; For now, require xs/ys to be provided
+ xs (or (:xs body-params)
+ (throw (ex-info "xs are required for continue" {})))
+ ys (or (:ys body-params)
+ (throw (ex-info "ys are required for continue" {})))
+
+ ;; Create new job
+ new-job-id (generate-job-id)
+ sse-channel (sse/create-event-channel)]
+
+ ;; Initialize new job state
+ (swap! jobs* assoc new-job-id {:status :pending
+ :progress nil
+ :result nil
+ :sse-channel sse-channel
+ :source-job source-job-id})
+
+ ;; Start solver in background with seeded formulas
+ (run-solver-job! new-job-id {:xs xs
+ :ys ys
+ :config config
+ :sse-channel sse-channel})
+
+ (log/info "Created continue job" new-job-id "from source" source-job-id
+ "with" (count seed-formulas) "seed formulas, fresh percent:" fresh-percent)
+
+ {:status 202
+ :headers {"Content-Type" "application/json"}
+ :body (json/encode {:jobId new-job-id
+ :sourceJobId source-job-id
+ :seedCount (count seed-formulas)
+ :eventsUrl (str "/api/jobs/" new-job-id "/events")})})
+ (catch Exception e
+ {:status 400
+ :headers {"Content-Type" "application/json"}
+ :body (json/encode {:error (.getMessage e)})}))
+ {:status 400
+ :headers {"Content-Type" "application/json"}
+ :body (json/encode {:error (str "Job must be completed or stopped to continue, current status: "
+ (name (:status source-job)))})})
+ {:status 404
+ :headers {"Content-Type" "application/json"}
+ :body (json/encode {:error "Source job not found"})})))
+
+
+(defn events
+ "GET /api/jobs/:id/events - SSE stream for job progress."
+ [{:keys [path-params]}]
+ (let [job-id (:id path-params)
+ job (get @jobs* job-id)]
+ (if job
+ (if-let [{:keys [channel]} (:sse-channel job)]
+ (cond
+ ;; If job is already complete, send result immediately
+ (= :completed (:status job))
+ (let [new-chan (async/chan 1)]
+ (async/put! new-chan {:event "complete" :data (:result job)})
+ (async/close! new-chan)
+ (sse/event-stream-response new-chan))
+
+ ;; If job failed, send error
+ (= :failed (:status job))
+ (let [new-chan (async/chan 1)]
+ (async/put! new-chan {:event "error" :data {:error (:error job)}})
+ (async/close! new-chan)
+ (sse/event-stream-response new-chan))
+
+ ;; Job is pending or running, stream updates
+ :else
+ (sse/event-stream-response channel))
+ ;; No SSE channel - job may have completed/failed, create one-shot response
+ (let [new-chan (async/chan 1)]
+ (cond
+ (= :completed (:status job))
+ (async/put! new-chan {:event "complete" :data (:result job)})
+ (= :failed (:status job))
+ (async/put! new-chan {:event "error" :data {:error (:error job)}})
+ :else
+ (async/put! new-chan {:event "error" :data {:error "Job channel not available"}}))
+ (async/close! new-chan)
+ (sse/event-stream-response new-chan)))
+ {:status 404
+ :headers {"Content-Type" "application/json"}
+ :body (json/encode {:error "Job not found"})})))
+
+
+(defn datasets
+ "GET /api/datasets - List available built-in datasets.
+ Datasets with :formula, :xMin, :xMax can be regenerated with different point counts."
+ [_]
+ (let [dataset-list [ {:id "h-line"
+ :name "HLine (y=0)"
+ :formula "h-line"
+ :xMin 0
+ :xMax 10
+ :xs [1 2 3 4 5]
+ :ys [0 0 0 0 0]}
+ {:id "nguyen4"
+ :name "Nguyen-4 (x^6+x^5+x^4+x^3+x^2+x)"
+ :formula "nguyen4"
+ :xMin -1
+ :xMax 1
+ :xs [-1 -0.5 0 0.5 1]
+ :ys (mapv (fn [x] (+ (Math/pow x 6) (Math/pow x 5) (Math/pow x 4)
+ (Math/pow x 3) (Math/pow x 2) x))
+ [-1 -0.5 0 0.5 1])}
+ {:id "nguyen5"
+ :name "Nguyen-5 (sin(x^2)*cos(x)-1)"
+ :formula "nguyen5"
+ :xMin -1
+ :xMax 1
+ :xs [-1 -0.5 0 0.5 1]
+ :ys (mapv (fn [x] (- (* (Math/sin (* x x)) (Math/cos x)) 1))
+ [-1 -0.5 0 0.5 1])}
+ {:id "feynman-lorentz"
+ :name "Feynman Lorentz (1/sqrt(1-x^2))"
+ :formula "feynman-lorentz"
+ :xMin 0
+ :xMax 0.9
+ :xs [0 0.2 0.4 0.6 0.8]
+ :ys (mapv (fn [x] (/ 1.0 (Math/sqrt (- 1.0 (* x x)))))
+ [0 0.2 0.4 0.6 0.8])}
+ {:id "feynman-wave"
+ :name "Feynman Wave (sin(x))"
+ :formula "feynman-wave"
+ :xMin 0
+ :xMax (* 4 Math/PI)
+ :xs [0 1 2 3 4 5 6]
+ :ys (mapv #(Math/sin %) [0 1 2 3 4 5 6])}
+ {:id "feynman-diffraction"
+ :name "Feynman Diffraction (sin²(5x/2)/sin²(x/2))"
+ :formula "feynman-diffraction"
+ :xMin 0.1
+ :xMax (* 2 Math/PI)
+ :xs [0.5 1.0 1.5 2.0 2.5 3.0 4.0 5.0 6.0]
+ :ys (mapv (fn [x]
+ (let [sin-half (Math/sin (/ x 2.0))
+ sin-n-half (Math/sin (* 2.5 x))]
+ (if (< (Math/abs sin-half) 1e-10)
+ 25.0
+ (/ (* sin-n-half sin-n-half)
+ (* sin-half sin-half)))))
+ [0.5 1.0 1.5 2.0 2.5 3.0 4.0 5.0 6.0])}
+ {:id "feynman-planck"
+ :name "Feynman Planck (x³/(e^x-1))"
+ :formula "feynman-planck"
+ :xMin 0.1
+ :xMax 5.0
+ :xs [0.5 1.0 1.5 2.0 2.5 3.0 3.5 4.0 4.5]
+ :ys (mapv (fn [x] (/ (* x x x) (- (Math/exp x) 1.0)))
+ [0.5 1.0 1.5 2.0 2.5 3.0 3.5 4.0 4.5])}
+ {:id "feynman-rutherford"
+ :name "Feynman Rutherford (1/sin⁴(x/2))"
+ :formula "feynman-rutherford"
+ :xMin 0.3
+ :xMax Math/PI
+ :xs [0.5 0.8 1.0 1.3 1.6 2.0 2.5 3.0]
+ :ys (mapv (fn [x]
+ (let [sin-half (Math/sin (/ x 2.0))]
+ (/ 1.0 (* sin-half sin-half sin-half sin-half))))
+ [0.5 0.8 1.0 1.3 1.6 2.0 2.5 3.0])}
+ {:id "feynman-ellipse"
+ :name "Feynman Ellipse (0.64/(1+0.6*cos(x)))"
+ :formula "feynman-ellipse"
+ :xMin 0
+ :xMax (* 2 Math/PI)
+ :xs [0 0.8 1.6 2.4 3.2 4.0 4.8 5.6 6.2]
+ :ys (mapv (fn [x]
+ (/ 0.64 (+ 1.0 (* 0.6 (Math/cos x)))))
+ [0 0.8 1.6 2.4 3.2 4.0 4.8 5.6 6.2])}
+ {:id "feynman-transition"
+ :name "Feynman Transition (sin²(x)/x²)"
+ :formula "feynman-transition"
+ :xMin -9
+ :xMax 9
+ :xs [-8 -6 -4 -2 -1 0 1 2 4 6 8]
+ :ys (mapv (fn [^double x]
+ (if (< (Math/abs x) 1e-10)
+ 1.0
+ (/ (* (Math/sin x) (Math/sin x))
+ (* x x))))
+ [-8 -6 -4 -2 -1 0 1 2 4 6 8])}
+ ;; Prime counting function π(x) - number of primes <= x (20 points)
+ {:id "prime-counting-20"
+ :name "Prime Counting π(x) [20 pts]"
+ :xs [10 20 30 40 50 60 70 80 90 100
+ 110 120 130 140 150 160 170 180 190 200]
+ :ys [4 8 10 12 15 17 19 22 24 25
+ 29 30 31 34 35 37 39 41 43 46]}
+ ;; Prime counting function π(x) - 40 points
+ {:id "prime-counting-40"
+ :name "Prime Counting π(x) [40 pts]"
+ :xs [5 10 15 20 25 30 35 40 45 50
+ 55 60 65 70 75 80 85 90 95 100
+ 105 110 115 120 125 130 135 140 145 150
+ 155 160 165 170 175 180 185 190 195 200]
+ :ys [3 4 6 8 9 10 11 12 14 15
+ 16 17 18 19 21 22 23 24 24 25
+ 27 29 30 30 30 31 32 34 34 35
+ 36 37 38 39 40 41 42 43 44 46]}
+ ;; Prime counting function π(x) - 100 points
+ {:id "prime-counting-100"
+ :name "Prime Counting π(x) [100 pts]"
+ :xs [2 4 6 8 10 12 14 16 18 20
+ 22 24 26 28 30 32 34 36 38 40
+ 42 44 46 48 50 52 54 56 58 60
+ 62 64 66 68 70 72 74 76 78 80
+ 82 84 86 88 90 92 94 96 98 100
+ 102 104 106 108 110 112 114 116 118 120
+ 122 124 126 128 130 132 134 136 138 140
+ 142 144 146 148 150 152 154 156 158 160
+ 162 164 166 168 170 172 174 176 178 180
+ 182 184 186 188 190 192 194 196 198 200]
+ :ys [1 2 3 4 4 5 6 6 7 8
+ 8 9 9 9 10 11 11 11 12 12
+ 13 14 14 15 15 15 16 16 17 17
+ 18 18 18 19 19 20 21 21 21 22
+ 22 23 23 23 24 24 24 24 25 25
+ 26 27 27 28 29 29 30 30 30 30
+ 30 30 30 31 31 32 33 34 34 34
+ 34 34 34 35 35 36 36 37 37 37
+ 38 38 38 39 39 40 40 41 41 41
+ 42 42 42 43 43 44 44 45 46 46]}
+ ;; Nth prime - Prime(n) gives the nth prime number (20 points)
+ {:id "primes-20"
+ :name "Nth Prime P(n) [20 pts]"
+ :xs [1 2 3 4 5 6 7 8 9 10
+ 11 12 13 14 15 16 17 18 19 20]
+ :ys [2 3 5 7 11 13 17 19 23 29
+ 31 37 41 43 47 53 59 61 67 71]}
+ ;; Nth prime - 40 points
+ {:id "primes-40"
+ :name "Nth Prime P(n) [40 pts]"
+ :xs [1 2 3 4 5 6 7 8 9 10
+ 11 12 13 14 15 16 17 18 19 20
+ 21 22 23 24 25 26 27 28 29 30
+ 31 32 33 34 35 36 37 38 39 40]
+ :ys [2 3 5 7 11 13 17 19 23 29
+ 31 37 41 43 47 53 59 61 67 71
+ 73 79 83 89 97 101 103 107 109 113
+ 127 131 137 139 149 151 157 163 167 173]}
+ ;; Nth prime - 100 points
+ {:id "primes-100"
+ :name "Nth Prime P(n) [100 pts]"
+ :xs (vec (range 1 101))
+ :ys [2 3 5 7 11 13 17 19 23 29
+ 31 37 41 43 47 53 59 61 67 71
+ 73 79 83 89 97 101 103 107 109 113
+ 127 131 137 139 149 151 157 163 167 173
+ 179 181 191 193 197 199 211 223 227 229
+ 233 239 241 251 257 263 269 271 277 281
+ 283 293 307 311 313 317 331 337 347 349
+ 353 359 367 373 379 383 389 397 401 409
+ 419 421 431 433 439 443 449 457 461 463
+ 467 479 487 491 499 503 509 521 523 541]}]]
+ {:status 200
+ :headers {"Content-Type" "application/json"}
+ :body (json/encode {:datasets dataset-list})}))
+
+
+(defn mutations
+ "GET /api/mutations - List all available mutation labels for filtering."
+ [_]
+ (let [all-labels (ops-init/mutation-labels)]
+ {:status 200
+ :headers {"Content-Type" "application/json"}
+ :body (json/encode {:mutations all-labels
+ :count (count all-labels)})}))
+
+
+(defn upload-csv
+ "POST /api/upload-csv - Parse uploaded CSV file content.
+ Body: {:content \"x,y\\n1,1\\n2,4\\n3,9\"}"
+ [{:keys [body-params]}]
+ (try
+ (let [content (:content body-params)
+ _ (when (nil? content)
+ (throw (ex-info "content is required" {})))
+ reader (StringReader. content)
+ csv-data (doall (csv/read-csv reader))
+ ;; Check for headers
+ has-headers (or (= "x" (clojure.string/lower-case (ffirst csv-data)))
+ (= "y" (clojure.string/lower-case (ffirst csv-data))))
+ data-rows (if has-headers (rest csv-data) csv-data)
+ parsed (mapv (fn [row]
+ {:x (Double/parseDouble (first row))
+ :y (Double/parseDouble (second row))})
+ data-rows)
+ xs (mapv :x parsed)
+ ys (mapv :y parsed)]
+ {:status 200
+ :headers {"Content-Type" "application/json"}
+ :body (json/encode {:xs xs :ys ys :rowCount (count parsed)})})
+ (catch Exception e
+ {:status 400
+ :headers {"Content-Type" "application/json"}
+ :body (json/encode {:error (str "Failed to parse CSV: " (.getMessage e))})})))
diff --git a/src/closyr/web/handlers/pages.clj b/src/closyr/web/handlers/pages.clj
new file mode 100644
index 00000000..b495638a
--- /dev/null
+++ b/src/closyr/web/handlers/pages.clj
@@ -0,0 +1,41 @@
+(ns closyr.web.handlers.pages
+ "HTML page handlers using Selmer templates."
+ (:require
+ [cheshire.core :as json]
+ [closyr.web.handlers.api :as api]
+ [selmer.parser :as selmer]))
+
+
+(set! *warn-on-reflection* true)
+
+
+;; Configure Selmer to use resources/templates
+(selmer/set-resource-path! (clojure.java.io/resource "templates"))
+
+
+(defn- render
+ "Render a template with the given context."
+ [template-name context]
+ {:status 200
+ :headers {"Content-Type" "text/html; charset=utf-8"}
+ :body (selmer/render-file template-name context)})
+
+
+;; ============================================================================
+;; Page Handlers
+;; ============================================================================
+
+(defn index
+ "GET / - Landing page."
+ [_]
+ (render "index.html" {:title "Closyr - Symbolic Regression"}))
+
+
+(defn solver
+ "GET /solver - Main solver page with form."
+ [_]
+ ;; Get datasets for the preset dropdown
+ (let [datasets-response (api/datasets nil)
+ datasets (-> datasets-response :body (json/parse-string true) :datasets)]
+ (render "solver.html" {:title "Solver - Closyr"
+ :datasets datasets})))
diff --git a/src/closyr/web/middleware.clj b/src/closyr/web/middleware.clj
new file mode 100644
index 00000000..b320c03e
--- /dev/null
+++ b/src/closyr/web/middleware.clj
@@ -0,0 +1,69 @@
+(ns closyr.web.middleware
+ "Ring middleware for JSON parsing, CORS, and error handling."
+ (:require
+ [cheshire.core :as json]
+ [closyr.util.log :as log]))
+
+
+(set! *warn-on-reflection* true)
+
+
+(defn wrap-json-body
+ "Middleware to parse JSON request bodies into :body-params."
+ [handler]
+ (fn [request]
+ (let [content-type (get-in request [:headers "content-type"] "")
+ body (:body request)]
+ (if (and body
+ (instance? java.io.InputStream body)
+ (or (.contains ^String content-type "application/json")
+ (.contains ^String content-type "text/json")))
+ (try
+ (let [body-str (slurp body)
+ body-params (when (seq body-str)
+ (json/parse-string body-str true))]
+ (handler (assoc request :body-params body-params)))
+ (catch Exception e
+ (log/warn "Failed to parse JSON body:" (.getMessage e))
+ {:status 400
+ :headers {"Content-Type" "application/json"}
+ :body (json/encode {:error "Invalid JSON body"})}))
+ (handler request)))))
+
+
+(defn wrap-json-response
+ "Middleware to encode response bodies as JSON when appropriate."
+ [handler]
+ (fn [request]
+ (let [response (handler request)]
+ (if (and (map? (:body response))
+ (not (get-in response [:headers "Content-Type"])))
+ (-> response
+ (assoc-in [:headers "Content-Type"] "application/json")
+ (update :body json/encode))
+ response))))
+
+
+(defn wrap-cors
+ "Middleware to add CORS headers for API access."
+ [handler]
+ (fn [request]
+ (let [response (handler request)]
+ (-> response
+ (assoc-in [:headers "Access-Control-Allow-Origin"] "*")
+ (assoc-in [:headers "Access-Control-Allow-Methods"] "GET, POST, OPTIONS")
+ (assoc-in [:headers "Access-Control-Allow-Headers"] "Content-Type")))))
+
+
+(defn wrap-exceptions
+ "Middleware to catch exceptions and return JSON error responses."
+ [handler]
+ (fn [request]
+ (try
+ (handler request)
+ (catch Exception e
+ (log/error "Unhandled exception in request handler:" (.getMessage e))
+ {:status 500
+ :headers {"Content-Type" "application/json"}
+ :body (json/encode {:error "Internal server error"
+ :message (.getMessage e)})}))))
diff --git a/src/closyr/web/routes.clj b/src/closyr/web/routes.clj
new file mode 100644
index 00000000..54251842
--- /dev/null
+++ b/src/closyr/web/routes.clj
@@ -0,0 +1,58 @@
+(ns closyr.web.routes
+ "Route definitions for the web application."
+ (:require
+ [closyr.web.handlers.api :as api]
+ [closyr.web.handlers.pages :as pages]
+ [closyr.web.middleware :as mw]
+ [reitit.ring :as ring]
+ [ring.middleware.defaults :refer [site-defaults wrap-defaults]]
+ [ring.middleware.multipart-params :refer [wrap-multipart-params]]))
+
+
+(set! *warn-on-reflection* true)
+
+
+(def routes
+ "Application routes."
+ [["/" {:get pages/index}]
+ ["/solver" {:get pages/solver}]
+
+ ;; API endpoints
+ ["/api"
+ ["/solve" {:post api/solve}]
+ ["/jobs/:id" {:get api/get-job}]
+ ["/jobs/:id/stop" {:post api/stop-job}]
+ ["/jobs/:id/pause" {:post api/pause-job}]
+ ["/jobs/:id/resume" {:post api/resume-job}]
+ ["/jobs/:id/continue" {:post api/continue-job}]
+ ["/jobs/:id/events" {:get api/events}]
+ ["/datasets" {:get api/datasets}]
+ ["/mutations" {:get api/mutations}]
+ ["/upload-csv" {:post api/upload-csv}]]])
+
+
+(def app
+ "Ring handler with middleware."
+ (ring/ring-handler
+ (ring/router routes)
+
+ ;; Default handlers for static files and 404
+ (ring/routes
+ (ring/create-resource-handler {:path "/"})
+ (ring/create-default-handler))
+
+ ;; Middleware stack
+ {:middleware [;; Parse multipart form data (for file uploads)
+ wrap-multipart-params
+ ;; JSON body parsing
+ mw/wrap-json-body
+ ;; JSON response encoding
+ mw/wrap-json-response
+ ;; CORS headers
+ mw/wrap-cors
+ ;; Exception handling
+ mw/wrap-exceptions
+ ;; Standard site defaults (but disable CSRF for API)
+ [wrap-defaults (-> site-defaults
+ (assoc-in [:security :anti-forgery] false)
+ (assoc-in [:responses :content-types] true))]]}))
diff --git a/src/closyr/web/server.clj b/src/closyr/web/server.clj
new file mode 100644
index 00000000..14cef633
--- /dev/null
+++ b/src/closyr/web/server.clj
@@ -0,0 +1,52 @@
+(ns closyr.web.server
+ "HTTP server lifecycle management using Jetty."
+ (:require
+ [closyr.util.log :as log]
+ [closyr.web.routes :as routes]
+ [ring.adapter.jetty :as jetty])
+ (:import (org.eclipse.jetty.server Server)))
+
+
+(set! *warn-on-reflection* true)
+
+
+(defonce ^:private server* (atom nil))
+
+
+(defn start!
+ "Start the HTTP server on the given port.
+ Returns the server instance."
+ ([]
+ (start! {}))
+ ([{:keys [port] :or {port 3000}}]
+ (when @server*
+ (log/warn "Server already running, stopping first...")
+ (.stop ^Server @server*))
+ (log/info "Starting web server on port" port)
+ (let [server (jetty/run-jetty #'routes/app
+ {:port port
+ :join? false})]
+ (reset! server* server)
+ (log/info "Web server started at http://localhost:" port)
+ server)))
+
+
+(defn stop!
+ "Stop the running HTTP server."
+ []
+ (when-let [^Server server @server*]
+ (log/info "Stopping web server...")
+ (.stop server)
+ (reset! server* nil)
+ (log/info "Web server stopped")))
+
+
+(defn running?
+ "Check if the server is currently running."
+ []
+ (some? @server*))
+
+
+(comment
+ (start!)
+ (stop!))
diff --git a/src/closyr/web/sse.clj b/src/closyr/web/sse.clj
new file mode 100644
index 00000000..4163a03f
--- /dev/null
+++ b/src/closyr/web/sse.clj
@@ -0,0 +1,92 @@
+(ns closyr.web.sse
+ "Server-Sent Events (SSE) implementation for streaming solver progress."
+ (:require
+ [cheshire.core :as json]
+ [clojure.core.async :as async :refer [! chan close! go-loop timeout]]
+ [closyr.util.log :as log]
+ [ring.core.protocols :as ring-protocols])
+ (:import
+ (java.io OutputStream)))
+
+
+(set! *warn-on-reflection* true)
+
+
+(defn format-sse-event
+ "Format a map as an SSE event string.
+ Supports optional event type."
+ ([data]
+ (format-sse-event nil data))
+ ([event-type data]
+ (let [json-data (json/encode data)]
+ (str (when event-type
+ (str "event: " event-type "\n"))
+ "data: " json-data "\n\n"))))
+
+
+(defn create-event-channel
+ "Create a channel for SSE events.
+ Returns {:channel ch :send! fn :close! fn}"
+ []
+ (let [ch (chan 100)]
+ {:channel ch
+ :send! (fn [event-type data]
+ (async/put! ch {:event event-type :data data}))
+ :close! (fn []
+ (close! ch))}))
+
+
+;; Custom type for SSE streaming that implements Ring's protocol
+(deftype SSEBody [event-chan]
+ ring-protocols/StreamableResponseBody
+ (write-body-to-stream [_ response output-stream]
+ (let [^OutputStream os output-stream]
+ (try
+ (loop []
+ (when-let [{:keys [event data]} (async/SSEBody event-chan)})
+
+
+(defn send-heartbeat!
+ "Send a heartbeat comment to keep the SSE connection alive.
+ Call this periodically (e.g., every 15 seconds) for long-running operations."
+ [^OutputStream output-stream]
+ (try
+ (.write output-stream (.getBytes ": heartbeat\n\n" "UTF-8"))
+ (.flush output-stream)
+ true
+ (catch Exception _
+ false)))
diff --git a/src/main/java/org/closyr/api/ClojureBridge.java b/src/main/java/org/closyr/api/ClojureBridge.java
new file mode 100644
index 00000000..424987e4
--- /dev/null
+++ b/src/main/java/org/closyr/api/ClojureBridge.java
@@ -0,0 +1,66 @@
+package org.closyr.api;
+
+import clojure.java.api.Clojure;
+import clojure.lang.IFn;
+
+/**
+ * Bridge class that lazily loads Clojure namespaces and provides
+ * access to the symbolic regression solver.
+ *
+ * This class exists to avoid circular dependencies between Java
+ * compilation and Clojure AOT compilation.
+ */
+public class ClojureBridge {
+
+ private static volatile boolean initialized = false;
+ private static IFn findFn;
+ private static IFn configFn;
+
+ private ClojureBridge() {
+ // Utility class
+ }
+
+ /**
+ * Initialize the Clojure runtime. Safe to call multiple times.
+ */
+ public static synchronized void initialize() {
+ if (initialized) {
+ return;
+ }
+
+ // Require the API namespaces
+ IFn require = Clojure.var("clojure.core", "require");
+ require.invoke(Clojure.read("closyr.api.finder"));
+ require.invoke(Clojure.read("closyr.api.types"));
+
+ // Get references to the functions we need
+ findFn = Clojure.var("closyr.api.finder", "-find");
+ configFn = Clojure.var("closyr.api.types", "config");
+
+ initialized = true;
+ }
+
+ /**
+ * Find a formula using default configuration.
+ */
+ public static IFormulaResult find(double[] xs, double[] ys) {
+ initialize();
+ return (IFormulaResult) findFn.invoke(xs, ys);
+ }
+
+ /**
+ * Find a formula with custom configuration.
+ */
+ public static IFormulaResult find(double[] xs, double[] ys, IFormulaConfig config) {
+ initialize();
+ return (IFormulaResult) findFn.invoke(xs, ys, config);
+ }
+
+ /**
+ * Create a default configuration.
+ */
+ public static IFormulaConfig createConfig() {
+ initialize();
+ return (IFormulaConfig) configFn.invoke();
+ }
+}
diff --git a/src/main/java/org/closyr/api/FormulaConfigBuilder.java b/src/main/java/org/closyr/api/FormulaConfigBuilder.java
new file mode 100644
index 00000000..facb19d8
--- /dev/null
+++ b/src/main/java/org/closyr/api/FormulaConfigBuilder.java
@@ -0,0 +1,225 @@
+package org.closyr.api;
+
+/**
+ * Fluent builder for creating IFormulaConfig instances.
+ *
+ * Usage:
+ *
+ * IFormulaConfig config = FormulaConfigBuilder.builder()
+ * .iterations(50)
+ * .populationSize(200)
+ * .maxLeafs(30)
+ * .build();
+ *
+ */
+public class FormulaConfigBuilder {
+
+ private int iterations = 20;
+ private int populationSize = 100;
+ private int maxLeafs = 40;
+ private long randomSeed = -1L;
+ private String[] mutationsWhitelist = null;
+ private String[] mutationsBlacklist = null;
+ private boolean adaptiveMode = false;
+ private boolean quietLogs = false;
+ private boolean useEvalCache = false;
+ private String scoringMethod = "mae-max";
+
+ private FormulaConfigBuilder() {
+ }
+
+ /**
+ * Create a new builder with default values.
+ */
+ public static FormulaConfigBuilder builder() {
+ return new FormulaConfigBuilder();
+ }
+
+ /**
+ * Set the number of GA iterations.
+ */
+ public FormulaConfigBuilder iterations(int iterations) {
+ this.iterations = iterations;
+ return this;
+ }
+
+ /**
+ * Set the population size.
+ */
+ public FormulaConfigBuilder populationSize(int populationSize) {
+ this.populationSize = populationSize;
+ return this;
+ }
+
+ /**
+ * Set the maximum expression tree leaf count.
+ */
+ public FormulaConfigBuilder maxLeafs(int maxLeafs) {
+ this.maxLeafs = maxLeafs;
+ return this;
+ }
+
+ /**
+ * Set the random seed for reproducible results.
+ * Use -1 for non-deterministic behavior (default).
+ */
+ public FormulaConfigBuilder randomSeed(long randomSeed) {
+ this.randomSeed = randomSeed;
+ return this;
+ }
+
+ /**
+ * Set a whitelist of mutation labels to use (only these mutations will be used).
+ */
+ public FormulaConfigBuilder mutationsWhitelist(String... labels) {
+ this.mutationsWhitelist = labels;
+ return this;
+ }
+
+ /**
+ * Set a blacklist of mutation labels to exclude (these mutations will not be used).
+ */
+ public FormulaConfigBuilder mutationsBlacklist(String... labels) {
+ this.mutationsBlacklist = labels;
+ return this;
+ }
+
+ /**
+ * Enable or disable adaptive mutation mode.
+ * When enabled, mutation rates adjust dynamically based on population diversity.
+ */
+ public FormulaConfigBuilder adaptiveMode(boolean adaptiveMode) {
+ this.adaptiveMode = adaptiveMode;
+ return this;
+ }
+
+ /**
+ * Enable or disable quiet logging mode.
+ * When enabled, detailed iteration logs are suppressed.
+ */
+ public FormulaConfigBuilder quietLogs(boolean quietLogs) {
+ this.quietLogs = quietLogs;
+ return this;
+ }
+
+ /**
+ * Enable or disable evaluation cache.
+ * When enabled, expression evaluation results are cached to avoid redundant calculations.
+ */
+ public FormulaConfigBuilder useEvalCache(boolean useEvalCache) {
+ this.useEvalCache = useEvalCache;
+ return this;
+ }
+
+ /**
+ * Set the scoring method for fitness evaluation.
+ * Valid values: "mae-max" (default), "log-cosh", "r-squared".
+ * All methods return 0 for perfect fit, negative for worse fits.
+ */
+ public FormulaConfigBuilder scoringMethod(String scoringMethod) {
+ this.scoringMethod = scoringMethod;
+ return this;
+ }
+
+ /**
+ * Build the configuration.
+ */
+ public IFormulaConfig build() {
+ return new SimpleFormulaConfig(iterations, populationSize, maxLeafs, randomSeed,
+ mutationsWhitelist, mutationsBlacklist, adaptiveMode, quietLogs, useEvalCache, scoringMethod);
+ }
+
+ /**
+ * Simple implementation of IFormulaConfig.
+ */
+ private static class SimpleFormulaConfig implements IFormulaConfig {
+ private final int iterations;
+ private final int populationSize;
+ private final int maxLeafs;
+ private final long randomSeed;
+ private final String[] mutationsWhitelist;
+ private final String[] mutationsBlacklist;
+ private final boolean adaptiveMode;
+ private final boolean quietLogs;
+ private final boolean useEvalCache;
+ private final String scoringMethod;
+
+ SimpleFormulaConfig(int iterations, int populationSize, int maxLeafs, long randomSeed,
+ String[] mutationsWhitelist, String[] mutationsBlacklist,
+ boolean adaptiveMode, boolean quietLogs, boolean useEvalCache,
+ String scoringMethod) {
+ this.iterations = iterations;
+ this.populationSize = populationSize;
+ this.maxLeafs = maxLeafs;
+ this.randomSeed = randomSeed;
+ this.mutationsWhitelist = mutationsWhitelist;
+ this.mutationsBlacklist = mutationsBlacklist;
+ this.adaptiveMode = adaptiveMode;
+ this.quietLogs = quietLogs;
+ this.useEvalCache = useEvalCache;
+ this.scoringMethod = scoringMethod;
+ }
+
+ @Override
+ public int getIterations() {
+ return iterations;
+ }
+
+ @Override
+ public int getPopulationSize() {
+ return populationSize;
+ }
+
+ @Override
+ public int getMaxLeafs() {
+ return maxLeafs;
+ }
+
+ @Override
+ public long getRandomSeed() {
+ return randomSeed;
+ }
+
+ @Override
+ public String[] getMutationsWhitelist() {
+ return mutationsWhitelist;
+ }
+
+ @Override
+ public String[] getMutationsBlacklist() {
+ return mutationsBlacklist;
+ }
+
+ @Override
+ public boolean isAdaptiveMode() {
+ return adaptiveMode;
+ }
+
+ @Override
+ public boolean isQuietLogs() {
+ return quietLogs;
+ }
+
+ @Override
+ public boolean isUseEvalCache() {
+ return useEvalCache;
+ }
+
+ @Override
+ public String getScoringMethod() {
+ return scoringMethod;
+ }
+
+ @Override
+ public String toString() {
+ return "FormulaConfig{iterations=" + iterations +
+ ", populationSize=" + populationSize +
+ ", maxLeafs=" + maxLeafs +
+ ", randomSeed=" + randomSeed +
+ ", adaptiveMode=" + adaptiveMode +
+ ", quietLogs=" + quietLogs +
+ ", useEvalCache=" + useEvalCache +
+ ", scoringMethod=" + scoringMethod + "}";
+ }
+ }
+}
diff --git a/src/main/java/org/closyr/api/IFormulaConfig.java b/src/main/java/org/closyr/api/IFormulaConfig.java
new file mode 100644
index 00000000..5c506f83
--- /dev/null
+++ b/src/main/java/org/closyr/api/IFormulaConfig.java
@@ -0,0 +1,79 @@
+package org.closyr.api;
+
+/**
+ * Configuration for the symbolic regression solver.
+ */
+public interface IFormulaConfig {
+
+ /**
+ * Get the number of GA iterations to run.
+ */
+ int getIterations();
+
+ /**
+ * Get the population size (number of candidate formulas).
+ */
+ int getPopulationSize();
+
+ /**
+ * Get the maximum number of leaf nodes allowed in expression trees.
+ */
+ int getMaxLeafs();
+
+ /**
+ * Get the random seed for reproducible results.
+ * A value of -1 means no seed is set (non-deterministic).
+ */
+ default long getRandomSeed() {
+ return -1L;
+ }
+
+ /**
+ * Get the whitelist of mutation labels to use.
+ * If null or empty, all mutations are used (subject to blacklist).
+ */
+ default String[] getMutationsWhitelist() {
+ return null;
+ }
+
+ /**
+ * Get the blacklist of mutation labels to exclude.
+ * If null or empty, no mutations are excluded.
+ */
+ default String[] getMutationsBlacklist() {
+ return null;
+ }
+
+ /**
+ * Check if adaptive mutation mode is enabled.
+ * When enabled, mutation rates adjust dynamically based on population diversity.
+ */
+ default boolean isAdaptiveMode() {
+ return false;
+ }
+
+ /**
+ * Check if quiet logging mode is enabled.
+ * When enabled, detailed iteration logs are suppressed.
+ */
+ default boolean isQuietLogs() {
+ return false;
+ }
+
+ /**
+ * Check if evaluation cache is enabled.
+ * When enabled, expression evaluation results are cached to avoid redundant calculations.
+ */
+ default boolean isUseEvalCache() {
+ return false;
+ }
+
+ /**
+ * Get the scoring method for fitness evaluation.
+ * Valid values: "mae-max" (default), "log-cosh", "r-squared".
+ * All methods return 0 for perfect fit, negative for worse fits.
+ */
+ default String getScoringMethod() {
+ return "mae-max";
+ }
+}
diff --git a/src/main/java/org/closyr/api/IFormulaFinder.java b/src/main/java/org/closyr/api/IFormulaFinder.java
new file mode 100644
index 00000000..729012c7
--- /dev/null
+++ b/src/main/java/org/closyr/api/IFormulaFinder.java
@@ -0,0 +1,26 @@
+package org.closyr.api;
+
+/**
+ * Main interface for finding formulas via symbolic regression.
+ */
+public interface IFormulaFinder {
+
+ /**
+ * Find a formula that fits the given data points.
+ *
+ * @param xs array of x values (independent variable)
+ * @param ys array of y values (dependent variable, same length as xs)
+ * @return the result containing the best formula and all solutions
+ */
+ IFormulaResult findFormula(double[] xs, double[] ys);
+
+ /**
+ * Find a formula with custom configuration.
+ *
+ * @param xs array of x values
+ * @param ys array of y values
+ * @param config solver configuration
+ * @return the result containing the best formula and all solutions
+ */
+ IFormulaResult findFormula(double[] xs, double[] ys, IFormulaConfig config);
+}
diff --git a/src/main/java/org/closyr/api/IFormulaResult.java b/src/main/java/org/closyr/api/IFormulaResult.java
new file mode 100644
index 00000000..c1978f6a
--- /dev/null
+++ b/src/main/java/org/closyr/api/IFormulaResult.java
@@ -0,0 +1,42 @@
+package org.closyr.api;
+
+import java.util.List;
+
+/**
+ * Represents the result of running the symbolic regression solver.
+ */
+public interface IFormulaResult {
+
+ /**
+ * Get the best solution found.
+ */
+ IFormulaSolution getBestSolution();
+
+ /**
+ * Get all solutions from the final population, sorted by score (best first).
+ */
+ List getAllSolutions();
+
+ /**
+ * Get the number of GA iterations that were completed.
+ */
+ int getIterationsDone();
+
+ /**
+ * Get the best formula as a string (convenience method).
+ * Equivalent to getBestSolution().getFormula().
+ */
+ default String getBestFormula() {
+ IFormulaSolution best = getBestSolution();
+ return best != null ? best.getFormula() : null;
+ }
+
+ /**
+ * Get the best score (convenience method).
+ * Equivalent to getBestSolution().getScore().
+ */
+ default double getBestScore() {
+ IFormulaSolution best = getBestSolution();
+ return best != null ? best.getScore() : Double.NEGATIVE_INFINITY;
+ }
+}
diff --git a/src/main/java/org/closyr/api/IFormulaSolution.java b/src/main/java/org/closyr/api/IFormulaSolution.java
new file mode 100644
index 00000000..6a61fbe3
--- /dev/null
+++ b/src/main/java/org/closyr/api/IFormulaSolution.java
@@ -0,0 +1,32 @@
+package org.closyr.api;
+
+import org.matheclipse.core.interfaces.IExpr;
+
+/**
+ * Represents a single formula solution from the symbolic regression solver.
+ */
+public interface IFormulaSolution {
+
+ /**
+ * Get the formula as a human-readable string (e.g., "2*x + 1").
+ */
+ String getFormula();
+
+ /**
+ * Get the fitness score. Higher (closer to 0) is better.
+ * Scores are typically negative, with 0 being a perfect fit.
+ */
+ double getScore();
+
+ /**
+ * Get the formula as a Symja IExpr for further symbolic computation.
+ * May be null if the expression couldn't be extracted.
+ */
+ IExpr getExpr();
+
+ /**
+ * Get the number of leaf nodes in the expression tree.
+ * Smaller values indicate simpler formulas.
+ */
+ int getLeafCount();
+}
diff --git a/src/main/java/org/closyr/core/FindFormula.java b/src/main/java/org/closyr/core/FindFormula.java
new file mode 100644
index 00000000..9b284dd7
--- /dev/null
+++ b/src/main/java/org/closyr/core/FindFormula.java
@@ -0,0 +1,373 @@
+package org.closyr.core;
+
+import org.closyr.api.ClojureBridge;
+import org.closyr.api.FormulaConfigBuilder;
+import org.closyr.api.IFormulaConfig;
+import org.closyr.api.IFormulaResult;
+import org.closyr.api.IFormulaSolution;
+import org.matheclipse.core.eval.EvalEngine;
+import org.matheclipse.core.eval.interfaces.AbstractFunctionOptionEvaluator;
+import org.matheclipse.core.eval.interfaces.IFunctionEvaluator;
+import org.matheclipse.core.expression.F;
+import org.matheclipse.core.expression.ImplementationStatus;
+import org.matheclipse.core.interfaces.IAST;
+import org.matheclipse.core.interfaces.IExpr;
+import org.matheclipse.core.interfaces.ISymbol;
+
+import java.util.ArrayList;
+import java.util.List;
+
+
+/**
+ * FindFormula - A Symja function that uses genetic algorithm-based symbolic regression
+ * to find a formula that best fits the given data points.
+ *
+ * Usage from Symja:
+ *
+ * FindFormula[{{x1, y1}, {x2, y2}, ...}, x]
+ * FindFormula[{{x1, y1}, {x2, y2}, ...}, x, n]
+ * FindFormula[{{x1, y1}, {x2, y2}, ...}, x, n, All]
+ *
+ *
+ * Usage from Java:
+ *
+ * double[] xs = {1.0, 2.0, 3.0, 4.0, 5.0};
+ * double[] ys = {2.0, 4.0, 6.0, 8.0, 10.0};
+ * FindFormula.Result result = FindFormula.findFormula(xs, ys);
+ * System.out.println("Best formula: " + result.getFormulaString());
+ * System.out.println("Score: " + result.getScore());
+ *
+ *
+ * Or use the newer API directly:
+ *
+ * import org.closyr.api.*;
+ * IFormulaResult result = FormulaFinder.find(xs, ys);
+ *
+ */
+public class FindFormula extends AbstractFunctionOptionEvaluator {
+
+ /**
+ * Result of a symbolic regression run.
+ */
+ public static class Result {
+ private final String formulaString;
+ private final double score;
+ private final IExpr formulaExpr;
+ private final int iterationsDone;
+ private final List allSolutions;
+
+ public Result(String formulaString, double score, IExpr formulaExpr,
+ int iterationsDone, List allSolutions) {
+ this.formulaString = formulaString;
+ this.score = score;
+ this.formulaExpr = formulaExpr;
+ this.iterationsDone = iterationsDone;
+ this.allSolutions = allSolutions;
+ }
+
+ /** The formula as a human-readable string (e.g., "2*x + 1") */
+ public String getFormulaString() {
+ return formulaString;
+ }
+
+ /** The fitness score (higher/closer to 0 is better, negative values) */
+ public double getScore() {
+ return score;
+ }
+
+ /** The formula as a Symja IExpr for further symbolic computation */
+ public IExpr getFormulaExpr() {
+ return formulaExpr;
+ }
+
+ /** Number of GA iterations completed */
+ public int getIterationsDone() {
+ return iterationsDone;
+ }
+
+ /** All solutions from the final population, sorted by score (best first) */
+ public List getAllSolutions() {
+ return allSolutions;
+ }
+
+ @Override
+ public String toString() {
+ return "Result{formula='" + formulaString + "', score=" + score +
+ ", iterations=" + iterationsDone + "}";
+ }
+ }
+
+ /**
+ * A single solution (phenotype) from the genetic algorithm.
+ */
+ public static class Solution {
+ private final String formulaString;
+ private final double score;
+ private final IExpr formulaExpr;
+
+ public Solution(String formulaString, double score, IExpr formulaExpr) {
+ this.formulaString = formulaString;
+ this.score = score;
+ this.formulaExpr = formulaExpr;
+ }
+
+ public String getFormulaString() {
+ return formulaString;
+ }
+
+ public double getScore() {
+ return score;
+ }
+
+ public IExpr getFormulaExpr() {
+ return formulaExpr;
+ }
+
+ @Override
+ public String toString() {
+ return "Solution{formula='" + formulaString + "', score=" + score + "}";
+ }
+ }
+
+ /**
+ * Configuration for the symbolic regression solver.
+ */
+ public static class Config {
+ private int iterations = 20;
+ private int populationSize = 100;
+ private int maxLeafs = 40;
+ private long randomSeed = -1L;
+ private String[] mutationsWhitelist = null;
+ private String[] mutationsBlacklist = null;
+ private boolean adaptiveMode = false;
+ private boolean quietLogs = false;
+ private boolean useEvalCache = false;
+ private String scoringMethod = "mae-max";
+
+ public Config() {}
+
+ /** Number of GA iterations (default: 20) */
+ public Config iterations(int iterations) {
+ this.iterations = iterations;
+ return this;
+ }
+
+ /** Population size (default: 100) */
+ public Config populationSize(int populationSize) {
+ this.populationSize = populationSize;
+ return this;
+ }
+
+ /** Maximum number of leaves in expression tree (default: 40) */
+ public Config maxLeafs(int maxLeafs) {
+ this.maxLeafs = maxLeafs;
+ return this;
+ }
+
+ /** Random seed for reproducible results (default: -1, meaning non-deterministic) */
+ public Config randomSeed(long randomSeed) {
+ this.randomSeed = randomSeed;
+ return this;
+ }
+
+ /** Whitelist of mutation labels to use (only these mutations will be used) */
+ public Config mutationsWhitelist(String... labels) {
+ this.mutationsWhitelist = labels;
+ return this;
+ }
+
+ /** Blacklist of mutation labels to exclude (these mutations will not be used) */
+ public Config mutationsBlacklist(String... labels) {
+ this.mutationsBlacklist = labels;
+ return this;
+ }
+
+ /** Enable adaptive mutation rates (default: false) */
+ public Config adaptiveMode(boolean adaptiveMode) {
+ this.adaptiveMode = adaptiveMode;
+ return this;
+ }
+
+ /** Enable quiet logging mode (default: false) */
+ public Config quietLogs(boolean quietLogs) {
+ this.quietLogs = quietLogs;
+ return this;
+ }
+
+ /** Enable evaluation cache (default: false) */
+ public Config useEvalCache(boolean useEvalCache) {
+ this.useEvalCache = useEvalCache;
+ return this;
+ }
+
+ /**
+ * Set the scoring method for fitness evaluation.
+ * Valid values: "mae-max" (default), "log-cosh", "r-squared".
+ * All methods return 0 for perfect fit, negative for worse fits.
+ */
+ public Config scoringMethod(String scoringMethod) {
+ this.scoringMethod = scoringMethod;
+ return this;
+ }
+
+ public int getIterations() { return iterations; }
+ public int getPopulationSize() { return populationSize; }
+ public int getMaxLeafs() { return maxLeafs; }
+ public long getRandomSeed() { return randomSeed; }
+ public String[] getMutationsWhitelist() { return mutationsWhitelist; }
+ public String[] getMutationsBlacklist() { return mutationsBlacklist; }
+ public boolean isAdaptiveMode() { return adaptiveMode; }
+ public boolean isQuietLogs() { return quietLogs; }
+ public boolean isUseEvalCache() { return useEvalCache; }
+ public String getScoringMethod() { return scoringMethod; }
+
+ /** Convert to IFormulaConfig for the new API */
+ IFormulaConfig toFormulaConfig() {
+ return FormulaConfigBuilder.builder()
+ .iterations(iterations)
+ .populationSize(populationSize)
+ .maxLeafs(maxLeafs)
+ .randomSeed(randomSeed)
+ .mutationsWhitelist(mutationsWhitelist)
+ .mutationsBlacklist(mutationsBlacklist)
+ .adaptiveMode(adaptiveMode)
+ .quietLogs(quietLogs)
+ .useEvalCache(useEvalCache)
+ .scoringMethod(scoringMethod)
+ .build();
+ }
+ }
+
+ public FindFormula() {
+ // empty constructor for Symja
+ }
+
+ /**
+ * Find a formula that fits the given x,y data points using default settings.
+ *
+ * @param xs array of x values
+ * @param ys array of y values (same length as xs)
+ * @return Result containing the best formula found
+ */
+ public static Result findFormula(double[] xs, double[] ys) {
+ return findFormula(xs, ys, new Config());
+ }
+
+ /**
+ * Find a formula that fits the given x,y data points with custom configuration.
+ *
+ * @param xs array of x values
+ * @param ys array of y values (same length as xs)
+ * @param config configuration for the solver
+ * @return Result containing the best formula found
+ */
+ public static Result findFormula(double[] xs, double[] ys, Config config) {
+ // Delegate to the new API via ClojureBridge
+ IFormulaConfig formulaConfig = config.toFormulaConfig();
+ IFormulaResult apiResult = ClojureBridge.find(xs, ys, formulaConfig);
+
+ // Convert to legacy Result format
+ return convertResult(apiResult);
+ }
+
+ /**
+ * Convert from the new API result to the legacy Result format.
+ */
+ private static Result convertResult(IFormulaResult apiResult) {
+ List solutions = new ArrayList<>();
+
+ for (IFormulaSolution apiSolution : apiResult.getAllSolutions()) {
+ solutions.add(new Solution(
+ apiSolution.getFormula(),
+ apiSolution.getScore(),
+ apiSolution.getExpr()
+ ));
+ }
+
+ IFormulaSolution best = apiResult.getBestSolution();
+ String bestFormula = best != null ? best.getFormula() : "x";
+ double bestScore = best != null ? best.getScore() : Double.NEGATIVE_INFINITY;
+ IExpr bestExpr = best != null ? best.getExpr() : null;
+
+ return new Result(
+ bestFormula,
+ bestScore,
+ bestExpr,
+ apiResult.getIterationsDone(),
+ solutions
+ );
+ }
+
+ // ==================== Symja Integration ====================
+
+ /**
+ * Symja function evaluator implementation.
+ * Called when FindFormula[data, x] is evaluated in Symja.
+ */
+ @Override
+ public IExpr evaluate(IAST ast, final int argSize, final IExpr[] options,
+ final EvalEngine engine, IAST originalAST) {
+ IExpr data = ast.arg1();
+ IExpr x = ast.arg2();
+
+ if (!x.isVariable()) {
+ return F.NIL;
+ }
+
+ int[] isMatrix = data.isMatrix();
+ if (isMatrix == null || isMatrix[1] != 2 || !data.isList()) {
+ return F.NIL;
+ }
+
+ IAST matrix = (IAST) data;
+ double[][] doubleMatrix = matrix.toDoubleMatrix();
+ if (doubleMatrix == null) {
+ return F.NIL;
+ }
+
+ // Extract x and y values from the matrix
+ double[] xs = new double[doubleMatrix.length];
+ double[] ys = new double[doubleMatrix.length];
+ for (int i = 0; i < doubleMatrix.length; i++) {
+ xs[i] = doubleMatrix[i][0];
+ ys[i] = doubleMatrix[i][1];
+ }
+
+ // Parse optional arguments
+ Config config = new Config();
+ if (argSize > 2) {
+ int n = ast.arg3().toIntDefault(-1);
+ if (n > 0) {
+ config.iterations(n);
+ }
+ }
+
+ try {
+ Result result = findFormula(xs, ys, config);
+ IExpr formulaExpr = result.getFormulaExpr();
+
+ if (formulaExpr != null) {
+ return formulaExpr;
+ }
+ } catch (Exception e) {
+ System.err.println("FindFormula: " + e.getMessage());
+ }
+
+ return F.NIL;
+ }
+
+ @Override
+ public int status() {
+ return ImplementationStatus.EXPERIMENTAL;
+ }
+
+ @Override
+ public int[] expectedArgSize(IAST ast) {
+ return IFunctionEvaluator.ARGS_2_4;
+ }
+
+ @Override
+ public void setUp(final ISymbol newSymbol) {
+ super.setUp(newSymbol);
+ }
+}
diff --git a/test/closyr/adaptive_test.clj b/test/closyr/adaptive_test.clj
new file mode 100644
index 00000000..b433ce1b
--- /dev/null
+++ b/test/closyr/adaptive_test.clj
@@ -0,0 +1,191 @@
+(ns closyr.adaptive-test
+ (:require
+ [clojure.test :refer :all]
+ [closyr.adaptive :as adaptive]
+ [closyr.ga :as ga]
+ [closyr.test-utils :as test-utils]))
+
+
+(use-fixtures :each
+ (fn [f]
+ (adaptive/reset-adaptive-state!)
+ (f)))
+
+
+(deftest reset-adaptive-state-test
+ (testing "reset clears all state to defaults"
+ ;; Modify state first
+ (adaptive/update-adaptive-state! [-1.0 -2.0 -3.0 -4.0 -5.0])
+ (adaptive/update-adaptive-state! [-1.0 -2.0 -3.0 -4.0 -5.0])
+
+ ;; Verify state was modified
+ (is (seq (:best-score-history (adaptive/get-adaptive-state))))
+
+ ;; Reset and verify
+ (adaptive/reset-adaptive-state!)
+ (let [state (adaptive/get-adaptive-state)]
+ (is (= 0.8 (:mutation-probability state)))
+ (is (= 1.0 (:mutation-count-boost state)))
+ (is (empty? (:best-score-history state)))
+ (is (empty? (:diversity-history state)))
+ (is (= 0 (:stagnation-counter state)))
+ (is (nil? (:last-best-score state))))))
+
+
+(deftest calculate-diversity-test
+ (testing "diversity is 0 for identical scores"
+ ;; Use 20 elements so p90 index calculation works properly
+ (is (< (adaptive/calculate-diversity (vec (repeat 20 -1.0))) 0.01)))
+
+ (testing "diversity increases with score spread"
+ ;; Create populations of 20 with different spreads
+ (let [low-spread (mapv #(- -1.0 (* 0.01 %)) (range 20)) ; -1.0 to -1.19
+ high-spread (mapv #(- -1.0 (* 0.5 %)) (range 20)) ; -1.0 to -10.5
+ low-div (adaptive/calculate-diversity low-spread)
+ high-div (adaptive/calculate-diversity high-spread)]
+ (is (< low-div high-div))))
+
+ (testing "diversity is nil for empty scores"
+ (is (nil? (adaptive/calculate-diversity []))))
+
+ (testing "diversity is capped at 1.0"
+ (let [extreme-spread (mapv #(- -1.0 (* 100.0 %)) (range 20))]
+ (is (<= (adaptive/calculate-diversity extreme-spread) 1.0)))))
+
+
+(def ^:private test-scores-a (mapv #(- -5.0 (* 0.5 %)) (range 20))) ; -5.0 to -14.5
+(def ^:private test-scores-b (mapv #(- -4.0 (* 0.5 %)) (range 20))) ; -4.0 to -13.5 (improvement)
+
+
+(deftest stagnation-detection-test
+ (testing "stagnation counter increases when no improvement"
+ (adaptive/reset-adaptive-state!)
+ ;; First update establishes baseline
+ (adaptive/update-adaptive-state! test-scores-a)
+ (is (= 0 (:stagnation-counter (adaptive/get-adaptive-state))))
+
+ ;; Same score = stagnation
+ (adaptive/update-adaptive-state! test-scores-a)
+ (is (= 1 (:stagnation-counter (adaptive/get-adaptive-state))))
+
+ ;; Still same score
+ (adaptive/update-adaptive-state! test-scores-a)
+ (is (= 2 (:stagnation-counter (adaptive/get-adaptive-state)))))
+
+ (testing "stagnation counter resets on improvement"
+ (adaptive/reset-adaptive-state!)
+ ;; Establish baseline and stagnate
+ (adaptive/update-adaptive-state! test-scores-a)
+ (adaptive/update-adaptive-state! test-scores-a)
+ (adaptive/update-adaptive-state! test-scores-a)
+ (is (= 2 (:stagnation-counter (adaptive/get-adaptive-state))))
+
+ ;; Improvement resets counter (better score = less negative)
+ (adaptive/update-adaptive-state! test-scores-b)
+ (is (= 0 (:stagnation-counter (adaptive/get-adaptive-state))))))
+
+
+(deftest mutation-probability-adjustment-test
+ (testing "mutation probability stays at baseline initially"
+ (adaptive/reset-adaptive-state!)
+ (is (= 0.8 (adaptive/get-mutation-probability))))
+
+ (testing "mutation probability increases during stagnation"
+ (adaptive/reset-adaptive-state!)
+ ;; Stagnate past threshold (20 iterations needed)
+ (dotimes [_ 25]
+ (adaptive/update-adaptive-state! test-scores-a))
+ (is (> (adaptive/get-mutation-probability) 0.8)))
+
+ (testing "mutation probability is bounded"
+ (adaptive/reset-adaptive-state!)
+ ;; Extreme stagnation
+ (dotimes [_ 50]
+ (adaptive/update-adaptive-state! test-scores-a))
+ (is (<= (adaptive/get-mutation-probability) 0.95))
+ (is (>= (adaptive/get-mutation-probability) 0.5))))
+
+
+(deftest mutation-count-boost-test
+ (testing "boost starts at 1.0"
+ (adaptive/reset-adaptive-state!)
+ (is (= 1.0 (adaptive/get-mutation-count-boost))))
+
+ (testing "boost increases during stagnation"
+ (adaptive/reset-adaptive-state!)
+ ;; Stagnate past threshold (20 iterations needed)
+ (dotimes [_ 25]
+ (adaptive/update-adaptive-state! test-scores-a))
+ (is (> (adaptive/get-mutation-count-boost) 1.0)))
+
+ (testing "boost is bounded"
+ (adaptive/reset-adaptive-state!)
+ ;; Extreme stagnation
+ (dotimes [_ 50]
+ (adaptive/update-adaptive-state! test-scores-a))
+ (is (<= (adaptive/get-mutation-count-boost) 2.0))
+ (is (>= (adaptive/get-mutation-count-boost) 0.5))))
+
+
+(deftest should-mutate-test
+ (testing "should-mutate? returns boolean"
+ (adaptive/reset-adaptive-state!)
+ (is (boolean? (adaptive/should-mutate?))))
+
+ (testing "should-mutate? respects probability distribution"
+ (adaptive/reset-adaptive-state!)
+ ;; With default 80% mutation probability, most calls should return true
+ (let [results (repeatedly 100 adaptive/should-mutate?)
+ true-count (count (filter true? results))]
+ ;; Should be roughly 80%, allow for randomness (60-95%)
+ (is (> true-count 60))
+ (is (< true-count 95)))))
+
+
+(deftest format-adaptive-status-test
+ (testing "format-adaptive-status returns a string"
+ (adaptive/reset-adaptive-state!)
+ (let [status (adaptive/format-adaptive-status)]
+ (is (string? status))
+ (is (re-find #"Adaptive:" status))
+ (is (re-find #"mut=" status))
+ (is (re-find #"boost=" status))
+ (is (re-find #"stag=" status)))))
+
+
+(deftest ga-adaptive-mode-binding-test
+ (testing "GA respects *adaptive-mode* binding when false"
+ (binding [ga/*adaptive-mode* false]
+ ;; When adaptive mode is off, should use fixed sampler
+ ;; We can't easily test the internal behavior, but we can verify it doesn't crash
+ (is (not ga/*adaptive-mode*))))
+
+ (testing "GA respects *adaptive-mode* binding when true"
+ (binding [ga/*adaptive-mode* true]
+ (is ga/*adaptive-mode*))))
+
+
+(deftest history-tracking-test
+ (testing "best score history is tracked"
+ (adaptive/reset-adaptive-state!)
+ (let [scores-1 (mapv #(- -1.0 (* 0.1 %)) (range 20)) ; best = -1.0
+ scores-2 (mapv #(- -0.5 (* 0.1 %)) (range 20)) ; best = -0.5
+ scores-3 (mapv #(- -0.3 (* 0.1 %)) (range 20))] ; best = -0.3
+ (adaptive/update-adaptive-state! scores-1)
+ (adaptive/update-adaptive-state! scores-2)
+ (adaptive/update-adaptive-state! scores-3)
+
+ (let [history (:best-score-history (adaptive/get-adaptive-state))]
+ (is (= 3 (count history)))
+ ;; Most recent first
+ (is (= -0.3 (first history))))))
+
+ (testing "history is bounded to window size"
+ (adaptive/reset-adaptive-state!)
+ ;; Add more than window size (40) entries
+ (dotimes [i 50]
+ (let [scores (mapv #(- (- i) (* 0.1 %)) (range 20))]
+ (adaptive/update-adaptive-state! scores)))
+
+ (let [history (:best-score-history (adaptive/get-adaptive-state))]
+ (is (<= (count history) 40)))))
diff --git a/test/closyr/api_test.clj b/test/closyr/api_test.clj
new file mode 100644
index 00000000..392252ba
--- /dev/null
+++ b/test/closyr/api_test.clj
@@ -0,0 +1,290 @@
+(ns closyr.api-test
+ "Tests for the Java-friendly API classes."
+ (:require
+ [clojure.test :refer :all]
+ [closyr.api.types :as types]
+ [closyr.api.finder :as finder]
+ [closyr.test-utils :as test-utils])
+ (:import
+ (org.closyr.api
+ FormulaConfigBuilder
+ FormulaFinder
+ IFormulaConfig
+ IFormulaFinder
+ IFormulaResult
+ IFormulaSolution)))
+
+
+(use-fixtures :once test-utils/quiet-logging-fixture)
+
+
+;; ============================================================================
+;; FormulaConfigBuilder tests
+;; ============================================================================
+
+(deftest test-config-builder-defaults
+ (testing "Builder creates config with default values"
+ (let [config (.build (FormulaConfigBuilder/builder))]
+ (is (instance? IFormulaConfig config))
+ (is (= 20 (.getIterations config)))
+ (is (= 100 (.getPopulationSize config)))
+ (is (= 40 (.getMaxLeafs config))))))
+
+
+(deftest test-config-builder-custom-values
+ (testing "Builder accepts custom values"
+ (let [config (-> (FormulaConfigBuilder/builder)
+ (.iterations 50)
+ (.populationSize 200)
+ (.maxLeafs 30)
+ (.build))]
+ (is (= 50 (.getIterations config)))
+ (is (= 200 (.getPopulationSize config)))
+ (is (= 30 (.getMaxLeafs config))))))
+
+
+;; ============================================================================
+;; Clojure types tests
+;; ============================================================================
+
+(deftest test-clojure-config
+ (testing "Clojure config function creates valid IFormulaConfig"
+ (let [config (types/config {:iterations 10 :population-size 50 :max-leafs 25})]
+ (is (instance? IFormulaConfig config))
+ (is (= 10 (.getIterations config)))
+ (is (= 50 (.getPopulationSize config)))
+ (is (= 25 (.getMaxLeafs config))))))
+
+
+(deftest test-clojure-config-defaults
+ (testing "Clojure config uses defaults when not specified"
+ (let [config (types/config)]
+ (is (= 20 (.getIterations config)))
+ (is (= 100 (.getPopulationSize config)))
+ (is (= 40 (.getMaxLeafs config))))))
+
+
+;; ============================================================================
+;; FormulaFinder static methods
+;; ============================================================================
+
+(deftest test-formula-finder-static-find
+ (testing "FormulaFinder.find() static method works"
+ (let [xs (double-array [1.0 2.0 3.0 4.0 5.0])
+ ys (double-array [2.0 4.0 6.0 8.0 10.0])
+ config (-> (FormulaConfigBuilder/builder)
+ (.iterations 3)
+ (.populationSize 20)
+ (.build))
+ result (FormulaFinder/find xs ys config)]
+ (is (instance? IFormulaResult result))
+ (is (pos? (.getIterationsDone result)))
+ (is (some? (.getBestSolution result)))
+ (is (string? (.getBestFormula result)))
+ (is (number? (.getBestScore result))))))
+
+
+(deftest test-formula-finder-static-find-default-config
+ (testing "FormulaFinder.find() with default config"
+ (let [xs (double-array [1.0 2.0 3.0])
+ ys (double-array [1.0 4.0 9.0])
+ result (FormulaFinder/find xs ys)]
+ (is (instance? IFormulaResult result))
+ (is (= 20 (.getIterationsDone result))))))
+
+
+;; ============================================================================
+;; FormulaFinder instance methods
+;; ============================================================================
+
+(deftest test-formula-finder-instance
+ (testing "FormulaFinder instance implements IFormulaFinder"
+ (let [finder (FormulaFinder/create)]
+ (is (instance? IFormulaFinder finder)))))
+
+
+(deftest test-formula-finder-instance-find
+ (testing "FormulaFinder instance findFormula method"
+ (let [finder (FormulaFinder/create)
+ xs (double-array [1.0 2.0 3.0])
+ ys (double-array [2.0 4.0 6.0])
+ config (-> (FormulaConfigBuilder/builder)
+ (.iterations 2)
+ (.populationSize 10)
+ (.build))
+ result (.findFormula finder xs ys config)]
+ (is (instance? IFormulaResult result))
+ (is (some? (.getBestSolution result))))))
+
+
+;; ============================================================================
+;; IFormulaSolution tests
+;; ============================================================================
+
+(deftest test-formula-solution-properties
+ (testing "IFormulaSolution has expected properties"
+ (let [xs (double-array [1.0 2.0 3.0])
+ ys (double-array [3.0 6.0 9.0])
+ config (-> (FormulaConfigBuilder/builder)
+ (.iterations 2)
+ (.populationSize 10)
+ (.build))
+ result (FormulaFinder/find xs ys config)
+ best (.getBestSolution result)]
+ (is (instance? IFormulaSolution best))
+ (is (string? (.getFormula best)))
+ (is (not (empty? (.getFormula best))))
+ (is (number? (.getScore best)))
+ (is (>= (.getLeafCount best) 0)))))
+
+
+(deftest test-all-solutions-sorted
+ (testing "getAllSolutions returns solutions sorted by score"
+ (let [xs (double-array [1.0 2.0 3.0 4.0])
+ ys (double-array [1.0 4.0 9.0 16.0])
+ config (-> (FormulaConfigBuilder/builder)
+ (.iterations 3)
+ (.populationSize 30)
+ (.build))
+ result (FormulaFinder/find xs ys config)
+ solutions (.getAllSolutions result)]
+ (is (seq solutions))
+ ;; Check sorted descending by score
+ (doseq [[prev curr] (partition 2 1 solutions)]
+ (is (>= (.getScore prev) (.getScore curr))
+ "Solutions should be sorted by score descending")))))
+
+
+;; ============================================================================
+;; Validation tests
+;; ============================================================================
+
+(deftest test-null-xs-throws
+ (testing "Null xs throws IllegalArgumentException"
+ (let [ys (double-array [1.0 2.0 3.0])]
+ (is (thrown-with-msg? IllegalArgumentException #"null"
+ (FormulaFinder/find nil ys))))))
+
+
+(deftest test-null-ys-throws
+ (testing "Null ys throws IllegalArgumentException"
+ (let [xs (double-array [1.0 2.0 3.0])]
+ (is (thrown-with-msg? IllegalArgumentException #"null"
+ (FormulaFinder/find xs nil))))))
+
+
+(deftest test-mismatched-lengths-throws
+ (testing "Mismatched array lengths throws IllegalArgumentException"
+ (let [xs (double-array [1.0 2.0 3.0])
+ ys (double-array [1.0 2.0])]
+ (is (thrown-with-msg? IllegalArgumentException #"same length"
+ (FormulaFinder/find xs ys))))))
+
+
+(deftest test-too-few-points-throws
+ (testing "Less than 2 data points throws IllegalArgumentException"
+ (let [xs (double-array [1.0])
+ ys (double-array [2.0])]
+ (is (thrown-with-msg? IllegalArgumentException #"At least 2"
+ (FormulaFinder/find xs ys))))))
+
+
+;; ============================================================================
+;; toString tests
+;; ============================================================================
+
+(deftest test-result-to-string
+ (testing "Result toString contains expected info"
+ (let [xs (double-array [1.0 2.0 3.0])
+ ys (double-array [2.0 4.0 6.0])
+ config (-> (FormulaConfigBuilder/builder)
+ (.iterations 1)
+ (.populationSize 5)
+ (.build))
+ result (FormulaFinder/find xs ys config)
+ str-repr (.toString result)]
+ (is (.contains str-repr "FormulaResult"))
+ (is (.contains str-repr "bestFormula")))))
+
+
+(deftest test-solution-to-string
+ (testing "Solution toString contains expected info"
+ (let [xs (double-array [1.0 2.0 3.0])
+ ys (double-array [1.0 4.0 9.0])
+ config (-> (FormulaConfigBuilder/builder)
+ (.iterations 1)
+ (.populationSize 5)
+ (.build))
+ result (FormulaFinder/find xs ys config)
+ solution (.getBestSolution result)
+ str-repr (.toString solution)]
+ (is (.contains str-repr "FormulaSolution"))
+ (is (.contains str-repr "formula")))))
+
+
+;; ============================================================================
+;; Deterministic seed tests
+;; ============================================================================
+
+(deftest test-random-seed-produces-deterministic-results
+ (testing "Running solver with same seed produces identical results"
+ (let [xs (double-array [1.0 2.0 3.0 4.0 5.0])
+ ys (double-array [2.0 4.0 6.0 8.0 10.0])
+ seed 42
+ config (-> (FormulaConfigBuilder/builder)
+ (.iterations 3)
+ (.populationSize 20)
+ (.randomSeed seed)
+ (.build))
+ ;; Run solver twice with same seed
+ result1 (FormulaFinder/find xs ys config)
+ result2 (FormulaFinder/find xs ys config)
+ best1 (.getBestSolution result1)
+ best2 (.getBestSolution result2)]
+ ;; Results should be identical
+ (is (= (.getFormula best1) (.getFormula best2))
+ "Same seed should produce identical formulas")
+ (is (= (.getScore best1) (.getScore best2))
+ "Same seed should produce identical scores")
+ (is (= (.getLeafCount best1) (.getLeafCount best2))
+ "Same seed should produce identical leaf counts"))))
+
+
+(deftest test-different-seeds-produce-different-results
+ (testing "Running solver with different seeds produces different results"
+ (let [xs (double-array [1.0 2.0 3.0 4.0 5.0])
+ ys (double-array [1.0 4.0 9.0 16.0 25.0])
+ config1 (-> (FormulaConfigBuilder/builder)
+ (.iterations 3)
+ (.populationSize 20)
+ (.randomSeed 123)
+ (.build))
+ config2 (-> (FormulaConfigBuilder/builder)
+ (.iterations 3)
+ (.populationSize 20)
+ (.randomSeed 456)
+ (.build))
+ result1 (FormulaFinder/find xs ys config1)
+ result2 (FormulaFinder/find xs ys config2)
+ best1 (.getBestSolution result1)
+ best2 (.getBestSolution result2)
+ ;; Get all solution formulas to compare
+ all-formulas1 (set (map #(.getFormula %) (.getAllSolutions result1)))
+ all-formulas2 (set (map #(.getFormula %) (.getAllSolutions result2)))]
+ ;; The full population should differ between runs with different seeds
+ (is (not= all-formulas1 all-formulas2)
+ "Different seeds should produce different populations"))))
+
+
+(deftest test-config-builder-with-seed
+ (testing "FormulaConfigBuilder accepts random seed"
+ (let [config (-> (FormulaConfigBuilder/builder)
+ (.randomSeed 12345)
+ (.build))]
+ (is (= 12345 (.getRandomSeed config))))))
+
+
+(deftest test-clojure-config-with-seed
+ (testing "Clojure config accepts random seed"
+ (let [config (types/config {:random-seed 98765})]
+ (is (= 98765 (.getRandomSeed config))))))
diff --git a/test/closyr/benchmark_functions_test.clj b/test/closyr/benchmark_functions_test.clj
new file mode 100644
index 00000000..b31d3728
--- /dev/null
+++ b/test/closyr/benchmark_functions_test.clj
@@ -0,0 +1,446 @@
+(ns closyr.benchmark-functions-test
+ "Benchmark tests for standard symbolic regression test functions (Nguyen, Feynman)"
+ (:require
+ [clojure.test :refer :all]
+ [closyr.ops :as ops]
+ [closyr.ops.common :as ops-common]
+ [closyr.ops.initialize :as ops-init]
+ [closyr.symbolic-regression :as symreg]
+ [closyr.test-utils :as test-utils])
+ (:import
+ (java.text DecimalFormat)))
+
+
+(use-fixtures :once test-utils/quiet-logging-fixture)
+
+(alter-var-root #'symreg/*is-testing* (constantly true))
+
+
+;; =============================================================================
+;; Timing Utilities
+;; =============================================================================
+
+(def ^:private decimal-fmt (DecimalFormat. "0.00"))
+
+
+(defn- get-best-fn-str
+ "Extract the best formula string from the final population"
+ [final-population]
+ (let [{:keys [pop pop-scores]} final-population
+ best-idx (->> pop-scores
+ (map-indexed vector)
+ (apply max-key second)
+ first)
+ best-pheno (nth pop best-idx)]
+ (ops/format-fn-str (:expr best-pheno))))
+
+
+(defmacro with-timing
+ "Execute body and return [result elapsed-ms]"
+ [& body]
+ `(let [start# (System/nanoTime)
+ result# (do ~@body)
+ elapsed# (/ (- (System/nanoTime) start#) 1e6)]
+ [result# elapsed#]))
+
+
+(defn format-score [score]
+ (.format decimal-fmt score))
+
+
+(defn format-time [ms]
+ (if (>= ms 1000)
+ (str (.format decimal-fmt (/ ms 1000.0)) "s")
+ (str (.format decimal-fmt ms) "ms")))
+
+
+;; =============================================================================
+;; Benchmark Function Definitions
+;; =============================================================================
+
+(defn nguyen-4
+ "Nguyen-4: x^6 + x^5 + x^4 + x^3 + x^2 + x, x in [-1, 1]"
+ [x]
+ (+ (Math/pow x 6)
+ (Math/pow x 5)
+ (Math/pow x 4)
+ (Math/pow x 3)
+ (Math/pow x 2)
+ x))
+
+
+(defn nguyen-5
+ "Nguyen-5: sin(x^2) * cos(x) - 1, x in [-1, 1]"
+ [x]
+ (- (* (Math/sin (* x x))
+ (Math/cos x))
+ 1.0))
+
+
+(defn feynman-lorentz
+ "Lorentz factor: 1/sqrt(1 - v^2/c^2), v/c in [0, 0.95]"
+ [v-over-c]
+ (/ 1.0
+ (Math/sqrt (- 1.0 (* v-over-c v-over-c)))))
+
+
+(defn feynman-wave
+ "Wave equation: A * sin(kx - wt), with k=1, w=0.5, t=2, A=1"
+ [x]
+ (Math/sin (- x 1.0))) ; simplified: k=1, omega*t=1
+
+
+(defn feynman-diffraction
+ "Diffraction grating intensity: I = I0 * sin²(nθ/2) / sin²(θ/2), n=5, I0=1
+ From Feynman I.30.3"
+ [theta]
+ (let [n 5.0
+ half-theta (/ theta 2.0)
+ sin-half (Math/sin half-theta)
+ sin-n-half (Math/sin (* n half-theta))]
+ (if (< (Math/abs sin-half) 1e-10)
+ (* n n) ; limit as theta->0 is n²
+ (/ (* sin-n-half sin-n-half)
+ (* sin-half sin-half)))))
+
+
+(defn feynman-planck
+ "Planck radiation spectrum (simplified): x³ / (exp(x) - 1)
+ Core shape of black-body radiation. From Feynman I.41.16"
+ [x]
+ (if (< x 0.01)
+ (* x x) ; Taylor expansion near 0
+ (/ (* x x x)
+ (- (Math/exp x) 1.0))))
+
+
+(defn feynman-rutherford
+ "Rutherford scattering cross-section: 1 / sin⁴(θ/2)
+ Simplified from Feynman B1. θ in (0.1, π)"
+ [theta]
+ (let [sin-half (Math/sin (/ theta 2.0))]
+ (/ 1.0
+ (* sin-half sin-half sin-half sin-half))))
+
+
+(defn feynman-elliptical-orbit
+ "Elliptical orbit radius: r = a(1-e²) / (1 + e*cos(θ))
+ Kepler's first law. e=0.6 (eccentricity), a=1. From Feynman B3"
+ [theta]
+ (let [e 0.6
+ a 1.0]
+ (/ (* a (- 1.0 (* e e)))
+ (+ 1.0 (* e (Math/cos theta))))))
+
+
+(defn feynman-transition
+ "Quantum transition probability (sinc² function): sin²(x) / x²
+ From Feynman III.9.52: PI→II = (2πμEt/h)² × sin²((ω-ω₀)t/2) / ((ω-ω₀)t/2)²
+ Core shape where x = (ω-ω₀)t/2"
+ [x]
+ (if (< (Math/abs x) 1e-10)
+ 1.0 ; limit as x->0 is 1
+ (/ (* (Math/sin x) (Math/sin x))
+ (* x x))))
+
+
+;; =============================================================================
+;; Data Generation
+;; =============================================================================
+
+(defn generate-benchmark-data
+ "Generate x,y pairs for a benchmark function"
+ [f x-min x-max n-points]
+ (let [xs (mapv (fn [i]
+ (+ x-min
+ (* (/ i (dec (double n-points)))
+ (- x-max x-min))))
+ (range n-points))
+ ys (mapv f xs)]
+ {:xs xs :ys ys}))
+
+
+;; =============================================================================
+;; Benchmark Tests
+;; =============================================================================
+
+(deftest ^:benchmark nguyen-4-benchmark
+ (testing "Nguyen-4: polynomial x^6 + x^5 + x^4 + x^3 + x^2 + x"
+ (let [{:keys [xs ys]} (generate-benchmark-data nguyen-4 -1.0 1.0 30)
+ [{:keys [final-population iters-done]} elapsed-ms]
+ (with-timing
+ (binding [ops/*print-top-n* 1]
+ (with-redefs-fn {#'symreg/config->log-steps (fn [_ _] 50)}
+ (fn []
+ (symreg/run-find-formula
+ {:input-phenos-count 50
+ :initial-muts (ops-init/initial-mutations)
+ :iters 50
+ :use-gui? false
+ :use-flamechart false
+ :random-seed 42
+ :input-xs-exprs (ops-common/doubles->exprs xs)
+ :input-ys-exprs (ops-common/doubles->exprs ys)})))))
+ best-score (apply max (:pop-scores final-population))
+ best-fn-str (get-best-fn-str final-population)]
+
+ (is (= 50 (count (:pop final-population))))
+ (is (= 50 iters-done))
+ ;; Best score should be negative (error) and improving
+ (is (neg? best-score))
+ ;; With random-seed 42, the resulting formula should be deterministic
+ (is (= "1/(1/50+x*Csc(x)*(-0.1-x+2*(1/10+x^2+x*Sec(x))))"
+ best-fn-str)
+ "Expected formula for Nguyen-4 with seed 42")
+ (println (str "| Nguyen-4 | " (format-score best-score)
+ " | " (format-time elapsed-ms)
+ " | fn: " best-fn-str " |")))))
+
+
+(deftest ^:benchmark nguyen-5-benchmark
+ (testing "Nguyen-5: sin(x^2)*cos(x) - 1"
+ (let [{:keys [xs ys]} (generate-benchmark-data nguyen-5 -1.0 1.0 30)
+ [{:keys [final-population iters-done]} elapsed-ms]
+ (with-timing
+ (binding [ops/*print-top-n* 1]
+ (with-redefs-fn {#'symreg/config->log-steps (fn [_ _] 50)}
+ (fn []
+ (symreg/run-find-formula
+ {:input-phenos-count 50
+ :initial-muts (ops-init/initial-mutations)
+ :iters 50
+ :use-gui? false
+ :use-flamechart false
+ :random-seed 42
+ :input-xs-exprs (ops-common/doubles->exprs xs)
+ :input-ys-exprs (ops-common/doubles->exprs ys)})))))
+ best-score (apply max (:pop-scores final-population))
+ best-fn-str (get-best-fn-str final-population)]
+
+ (is (= 50 (count (:pop final-population))))
+ (is (= 50 iters-done))
+ (is (neg? best-score))
+ ;; With random-seed 42, the resulting formula should be deterministic
+ (is (= "11/100+0.0009*x-Cos(1.1*x)"
+ best-fn-str)
+ "Expected formula for Nguyen-5 with seed 42")
+ (println (str "| Nguyen-5 | " (format-score best-score)
+ " | " (format-time elapsed-ms)
+ " | fn: " best-fn-str " |")))))
+
+
+(deftest ^:benchmark feynman-lorentz-benchmark
+ (testing "Feynman Lorentz factor: 1/sqrt(1 - v^2/c^2)"
+ (let [{:keys [xs ys]} (generate-benchmark-data feynman-lorentz 0.0 0.9 30)
+ [{:keys [final-population iters-done]} elapsed-ms]
+ (with-timing
+ (binding [ops/*print-top-n* 1]
+ (with-redefs-fn {#'symreg/config->log-steps (fn [_ _] 50)}
+ (fn []
+ (symreg/run-find-formula
+ {:input-phenos-count 50
+ :initial-muts (ops-init/initial-mutations)
+ :iters 50
+ :use-gui? false
+ :use-flamechart false
+ :random-seed 42
+ :input-xs-exprs (ops-common/doubles->exprs xs)
+ :input-ys-exprs (ops-common/doubles->exprs ys)})))))
+ best-score (apply max (:pop-scores final-population))
+ best-fn-str (get-best-fn-str final-population)]
+
+ (is (= 50 (count (:pop final-population))))
+ (is (= 50 iters-done))
+ (is (neg? best-score))
+ ;; With random-seed 42, the resulting formula should be deterministic
+ (is (= "9/10+Sqrt(x)"
+ best-fn-str)
+ "Expected formula for Feynman Lorentz with seed 42")
+ (println (str "| Feynman Lorentz | " (format-score best-score)
+ " | " (format-time elapsed-ms)
+ " | fn: " best-fn-str " |")))))
+
+
+(deftest ^:benchmark feynman-wave-benchmark
+ (testing "Feynman Wave equation: sin(kx - wt)"
+ (let [{:keys [xs ys]} (generate-benchmark-data feynman-wave 0.0 (* 4 Math/PI) 30)
+ [{:keys [final-population iters-done]} elapsed-ms]
+ (with-timing
+ (binding [ops/*print-top-n* 1]
+ (with-redefs-fn {#'symreg/config->log-steps (fn [_ _] 50)}
+ (fn []
+ (symreg/run-find-formula
+ {:input-phenos-count 50
+ :initial-muts (ops-init/initial-mutations)
+ :iters 50
+ :use-gui? false
+ :use-flamechart false
+ :random-seed 42
+ :input-xs-exprs (ops-common/doubles->exprs xs)
+ :input-ys-exprs (ops-common/doubles->exprs ys)})))))
+ best-score (apply max (:pop-scores final-population))
+ best-fn-str (get-best-fn-str final-population)]
+
+ (is (= 50 (count (:pop final-population))))
+ (is (= 50 iters-done))
+ (is (neg? best-score))
+ ;; With random-seed 42, the resulting formula should be deterministic
+ (is (= "-0.011*(1/100+0.009*x-Cos(x))+Sin(x)"
+ best-fn-str)
+ "Expected formula for Feynman Wave with seed 42")
+ (println (str "| Feynman Wave | " (format-score best-score)
+ " | " (format-time elapsed-ms)
+ " | fn: " best-fn-str " |")))))
+
+
+(deftest ^:benchmark feynman-diffraction-benchmark
+ (testing "Feynman Diffraction: sin²(nθ/2) / sin²(θ/2)"
+ (let [{:keys [xs ys]} (generate-benchmark-data feynman-diffraction 0.1 (* 2 Math/PI) 30)
+ [{:keys [final-population iters-done]} elapsed-ms]
+ (with-timing
+ (binding [ops/*print-top-n* 1]
+ (with-redefs-fn {#'symreg/config->log-steps (fn [_ _] 50)}
+ (fn []
+ (symreg/run-find-formula
+ {:input-phenos-count 50
+ :initial-muts (ops-init/initial-mutations)
+ :iters 50
+ :use-gui? false
+ :use-flamechart false
+ :random-seed 42
+ :input-xs-exprs (ops-common/doubles->exprs xs)
+ :input-ys-exprs (ops-common/doubles->exprs ys)})))))
+ best-score (apply max (:pop-scores final-population))
+ best-fn-str (get-best-fn-str final-population)]
+
+ (is (= 50 (count (:pop final-population))))
+ (is (= 50 iters-done))
+ (is (neg? best-score))
+ (println (str "| Feynman Diffraction | " (format-score best-score)
+ " | " (format-time elapsed-ms)
+ " | fn: " best-fn-str " |")))))
+
+
+(deftest ^:benchmark feynman-planck-benchmark
+ (testing "Feynman Planck radiation: x³ / (exp(x) - 1)"
+ (let [{:keys [xs ys]} (generate-benchmark-data feynman-planck 0.1 5.0 30)
+ [{:keys [final-population iters-done]} elapsed-ms]
+ (with-timing
+ (binding [ops/*print-top-n* 1]
+ (with-redefs-fn {#'symreg/config->log-steps (fn [_ _] 50)}
+ (fn []
+ (symreg/run-find-formula
+ {:input-phenos-count 50
+ :initial-muts (ops-init/initial-mutations)
+ :iters 50
+ :use-gui? false
+ :use-flamechart false
+ :random-seed 42
+ :input-xs-exprs (ops-common/doubles->exprs xs)
+ :input-ys-exprs (ops-common/doubles->exprs ys)})))))
+ best-score (apply max (:pop-scores final-population))
+ best-fn-str (get-best-fn-str final-population)]
+
+ (is (= 50 (count (:pop final-population))))
+ (is (= 50 iters-done))
+ (is (neg? best-score))
+ (println (str "| Feynman Planck | " (format-score best-score)
+ " | " (format-time elapsed-ms)
+ " | fn: " best-fn-str " |")))))
+
+
+(deftest ^:benchmark feynman-rutherford-benchmark
+ (testing "Feynman Rutherford scattering: 1 / sin⁴(θ/2)"
+ (let [{:keys [xs ys]} (generate-benchmark-data feynman-rutherford 0.3 Math/PI 30)
+ [{:keys [final-population iters-done]} elapsed-ms]
+ (with-timing
+ (binding [ops/*print-top-n* 1]
+ (with-redefs-fn {#'symreg/config->log-steps (fn [_ _] 50)}
+ (fn []
+ (symreg/run-find-formula
+ {:input-phenos-count 50
+ :initial-muts (ops-init/initial-mutations)
+ :iters 50
+ :use-gui? false
+ :use-flamechart false
+ :random-seed 42
+ :input-xs-exprs (ops-common/doubles->exprs xs)
+ :input-ys-exprs (ops-common/doubles->exprs ys)})))))
+ best-score (apply max (:pop-scores final-population))
+ best-fn-str (get-best-fn-str final-population)]
+
+ (is (= 50 (count (:pop final-population))))
+ (is (= 50 iters-done))
+ (is (neg? best-score))
+ (println (str "| Feynman Rutherford | " (format-score best-score)
+ " | " (format-time elapsed-ms)
+ " | fn: " best-fn-str " |")))))
+
+
+(deftest ^:benchmark feynman-elliptical-orbit-benchmark
+ (testing "Feynman Elliptical orbit: a(1-e²) / (1 + e*cos(θ))"
+ (let [{:keys [xs ys]} (generate-benchmark-data feynman-elliptical-orbit 0.0 (* 2 Math/PI) 30)
+ [{:keys [final-population iters-done]} elapsed-ms]
+ (with-timing
+ (binding [ops/*print-top-n* 1]
+ (with-redefs-fn {#'symreg/config->log-steps (fn [_ _] 50)}
+ (fn []
+ (symreg/run-find-formula
+ {:input-phenos-count 50
+ :initial-muts (ops-init/initial-mutations)
+ :iters 50
+ :use-gui? false
+ :use-flamechart false
+ :random-seed 42
+ :input-xs-exprs (ops-common/doubles->exprs xs)
+ :input-ys-exprs (ops-common/doubles->exprs ys)})))))
+ best-score (apply max (:pop-scores final-population))
+ best-fn-str (get-best-fn-str final-population)]
+
+ (is (= 50 (count (:pop final-population))))
+ (is (= 50 iters-done))
+ (is (neg? best-score))
+ (println (str "| Feynman Elliptical | " (format-score best-score)
+ " | " (format-time elapsed-ms)
+ " | fn: " best-fn-str " |")))))
+
+
+(deftest ^:benchmark feynman-transition-benchmark
+ (testing "Feynman Transition (III.9.52): sin²(x) / x²"
+ (let [{:keys [xs ys]} (generate-benchmark-data feynman-transition (- (* 3 Math/PI)) (* 3 Math/PI) 30)
+ [{:keys [final-population iters-done]} elapsed-ms]
+ (with-timing
+ (binding [ops/*print-top-n* 1]
+ (with-redefs-fn {#'symreg/config->log-steps (fn [_ _] 50)}
+ (fn []
+ (symreg/run-find-formula
+ {:input-phenos-count 50
+ :initial-muts (ops-init/initial-mutations)
+ :iters 50
+ :use-gui? false
+ :use-flamechart false
+ :random-seed 42
+ :input-xs-exprs (ops-common/doubles->exprs xs)
+ :input-ys-exprs (ops-common/doubles->exprs ys)})))))
+ best-score (apply max (:pop-scores final-population))
+ best-fn-str (get-best-fn-str final-population)]
+
+ (is (= 50 (count (:pop final-population))))
+ (is (= 50 iters-done))
+ (is (neg? best-score))
+ (println (str "| Feynman Transition | " (format-score best-score)
+ " | " (format-time elapsed-ms)
+ " | fn: " best-fn-str " |")))))
+
+
+(deftest benchmark-data-generation
+ (testing "Data generation produces correct ranges"
+ (let [{:keys [xs ys]} (generate-benchmark-data nguyen-4 -1.0 1.0 10)]
+ (is (= 10 (count xs)))
+ (is (= 10 (count ys)))
+ (is (= -1.0 (first xs)))
+ (is (= 1.0 (last xs)))
+ ;; nguyen-4 at x=-1: (-1)^6 + (-1)^5 + (-1)^4 + (-1)^3 + (-1)^2 + (-1) = 1 - 1 + 1 - 1 + 1 - 1 = 0
+ (is (< (Math/abs (- (first ys) 0.0)) 1e-10))
+ ;; nguyen-4 at x=1: 1 + 1 + 1 + 1 + 1 + 1 = 6
+ (is (< (Math/abs (- (last ys) 6.0)) 1e-10)))))
diff --git a/test/closyr/core_test.clj b/test/closyr/core_test.clj
index f8f0e041..f3508ed2 100644
--- a/test/closyr/core_test.clj
+++ b/test/closyr/core_test.clj
@@ -2,7 +2,11 @@
(:require
[clojure.test :refer :all]
[closyr.core :as core]
- [closyr.symbolic-regression :as symreg]))
+ [closyr.symbolic-regression :as symreg]
+ [closyr.test-utils :as test-utils]))
+
+
+(use-fixtures :once test-utils/quiet-logging-fixture)
(deftest cli-options-test
@@ -16,17 +20,24 @@
(is (=
(let [test-input
'("-t" "-p1000" "foo" "-i" "200" "-y" "1,2,30,4,5,6,10" "-x" "0,1,2,3,4,5,6" "-l" "20" "-c"
- "-g" "debug")]
+ "-g" "debug")]
(#'core/validate-symreg-opts (#'core/parse-main-opts test-input)))
- {:headless true
- :log-level :debug
- :max-leafs 20
- :use-flamechart true
- :iterations 200
- :population 1000
- :xs [0.0 1.0 2.0 3.0 4.0 5.0 6.0]
- :ys [1.0 2.0 30.0 4.0 5.0 6.0 10.0]})))
+ {:headless true
+ :log-level :debug
+ :max-leafs 20
+ :use-flamechart true
+ :iterations 200
+ :population 1000
+ :xs [0.0 1.0 2.0 3.0 4.0 5.0 6.0]
+ :ys [1.0 2.0 30.0 4.0 5.0 6.0 10.0]
+ :seed nil
+ :adaptive-mode false
+ :quiet-logs false
+ :use-eval-cache false
+ :scoring-method :mae-max
+ :mutations-whitelist nil
+ :mutations-blacklist nil})))
(testing "if xs, also needs ys"
@@ -63,14 +74,21 @@
(let [test-input '("-t" "-p1000" "-i" "200" "-g" "wearn")]
(#'core/validate-symreg-opts (#'core/parse-main-opts test-input)))
- {:headless true,
- :log-level :info,
- :xs nil,
- :ys nil,
- :population 1000,
- :use-flamechart false,
- :iterations 200,
- :max-leafs 40})))
+ {:headless true,
+ :log-level :info,
+ :xs nil,
+ :ys nil,
+ :population 1000,
+ :use-flamechart false,
+ :iterations 200,
+ :max-leafs 40
+ :seed nil
+ :adaptive-mode false
+ :quiet-logs false
+ :use-eval-cache false
+ :scoring-method :mae-max
+ :mutations-whitelist nil
+ :mutations-blacklist nil})))
(testing "handles valid short options w csv data"
@@ -78,28 +96,42 @@
(let [test-input '("-t" "-p1000" "foo" "-i" "200" "-f" "resources/csvs/test_inputs_1.csv" "-g" "warn")]
(#'core/validate-symreg-opts (#'core/parse-main-opts test-input)))
- {:headless true
- :log-level :warn
- :max-leafs 40
- :use-flamechart false
- :iterations 200
- :population 1000
- :xs [0.0 1.0 2.0 3.0 4.0 6.0 15.0 20.0]
- :ys [1.0 1.0 1.0 2.0 3.0 0.0 -1.0 -12.0]})))
+ {:headless true
+ :log-level :warn
+ :max-leafs 40
+ :use-flamechart false
+ :iterations 200
+ :population 1000
+ :xs [0.0 1.0 2.0 3.0 4.0 6.0 15.0 20.0]
+ :ys [1.0 1.0 1.0 2.0 3.0 0.0 -1.0 -12.0]
+ :seed nil
+ :adaptive-mode false
+ :quiet-logs false
+ :use-eval-cache false
+ :scoring-method :mae-max
+ :mutations-whitelist nil
+ :mutations-blacklist nil})))
(testing "handles valid long options w data inline"
(is (=
(let [test-input '("--headless" "--population" "1000" "--iterations" "200" "--ys" "1,2,30,4,5,6,10" "--xs" "0,1,2,3,4,5,6" "--use-flamechart" "--max-leafs" "60" "--log-level" "error")]
(#'core/validate-symreg-opts (#'core/parse-main-opts test-input)))
- {:headless true
- :log-level :error
- :max-leafs 60
- :use-flamechart true
- :iterations 200
- :population 1000
- :xs [0.0 1.0 2.0 3.0 4.0 5.0 6.0]
- :ys [1.0 2.0 30.0 4.0 5.0 6.0 10.0]})))
+ {:headless true
+ :log-level :error
+ :max-leafs 60
+ :use-flamechart true
+ :iterations 200
+ :population 1000
+ :xs [0.0 1.0 2.0 3.0 4.0 5.0 6.0]
+ :ys [1.0 2.0 30.0 4.0 5.0 6.0 10.0]
+ :seed nil
+ :adaptive-mode false
+ :quiet-logs false
+ :use-eval-cache false
+ :scoring-method :mae-max
+ :mutations-whitelist nil
+ :mutations-blacklist nil})))
(testing "handles valid long options w csv data with columns"
@@ -107,14 +139,21 @@
(let [test-input '("--headless" "--population" "1000" "--iterations" "200" "-f" "resources/csvs/test_inputs_1.csv")]
(#'core/validate-symreg-opts (#'core/parse-main-opts test-input)))
- {:headless true
- :log-level :info
- :max-leafs 40
- :use-flamechart false
- :iterations 200
- :population 1000
- :xs [0.0 1.0 2.0 3.0 4.0 6.0 15.0 20.0]
- :ys [1.0 1.0 1.0 2.0 3.0 0.0 -1.0 -12.0]})))
+ {:headless true
+ :log-level :info
+ :max-leafs 40
+ :use-flamechart false
+ :iterations 200
+ :population 1000
+ :xs [0.0 1.0 2.0 3.0 4.0 6.0 15.0 20.0]
+ :ys [1.0 1.0 1.0 2.0 3.0 0.0 -1.0 -12.0]
+ :seed nil
+ :adaptive-mode false
+ :quiet-logs false
+ :use-eval-cache false
+ :scoring-method :mae-max
+ :mutations-whitelist nil
+ :mutations-blacklist nil})))
(testing "handles valid long options w csv data with columns with in order y,x"
@@ -122,28 +161,176 @@
(let [test-input '("--headless" "--population" "1000" "--iterations" "200" "-f" "resources/csvs/test_inputs_3.csv")]
(#'core/validate-symreg-opts (#'core/parse-main-opts test-input)))
- {:headless true
- :log-level :info
- :max-leafs 40
- :use-flamechart false
- :iterations 200
- :population 1000
- :xs [0.0 1.0 2.0 3.0 4.0 6.0 15.0 20.0 30.0 45.0 55.0 60.0]
- :ys [1.0 1.0 1.0 2.0 3.0 0.0 -1.0 -12.0 -22.0 -25.0 -10.0 10.0]})))
+ {:headless true
+ :log-level :info
+ :max-leafs 40
+ :use-flamechart false
+ :iterations 200
+ :population 1000
+ :xs [0.0 1.0 2.0 3.0 4.0 6.0 15.0 20.0 30.0 45.0 55.0 60.0]
+ :ys [1.0 1.0 1.0 2.0 3.0 0.0 -1.0 -12.0 -22.0 -25.0 -10.0 10.0]
+ :seed nil
+ :adaptive-mode false
+ :quiet-logs false
+ :use-eval-cache false
+ :scoring-method :mae-max
+ :mutations-whitelist nil
+ :mutations-blacklist nil})))
(testing "handles valid long options w csv data without columns"
(is (=
(let [test-input '("--headless" "--population" "1000" "--iterations" "200" "-f" "resources/csvs/test_inputs_2.csv")]
(#'core/validate-symreg-opts (#'core/parse-main-opts test-input)))
- {:headless true
- :log-level :info
- :max-leafs 40
- :use-flamechart false
- :iterations 200
- :population 1000
- :xs [0.0 1.0 2.0 3.0 4.0 6.0 15.0 20.0]
- :ys [1.0 1.0 1.0 2.0 3.0 0.0 -1.0 -12.0]}))))
+ {:headless true
+ :log-level :info
+ :max-leafs 40
+ :use-flamechart false
+ :iterations 200
+ :population 1000
+ :xs [0.0 1.0 2.0 3.0 4.0 6.0 15.0 20.0]
+ :ys [1.0 1.0 1.0 2.0 3.0 0.0 -1.0 -12.0]
+ :seed nil
+ :adaptive-mode false
+ :quiet-logs false
+ :use-eval-cache false
+ :scoring-method :mae-max
+ :mutations-whitelist nil
+ :mutations-blacklist nil}))))
+
+
+(deftest cli-mutations-whitelist-test
+ (testing "parses whitelist option with short flag"
+ (is (=
+ (let [test-input '("-t" "-p1000" "-i" "200" "-w" "+Sin,-Sin,+Cos")]
+ (#'core/validate-symreg-opts (#'core/parse-main-opts test-input)))
+
+ {:headless true
+ :log-level :info
+ :max-leafs 40
+ :use-flamechart false
+ :iterations 200
+ :population 1000
+ :xs nil
+ :ys nil
+ :seed nil
+ :adaptive-mode false
+ :quiet-logs false
+ :use-eval-cache false
+ :scoring-method :mae-max
+ :mutations-whitelist ["+Sin" "-Sin" "+Cos"]
+ :mutations-blacklist nil})))
+
+ (testing "parses whitelist option with long flag"
+ (is (=
+ (let [test-input '("--headless" "--population" "1000" "--iterations" "200"
+ "--mutations-whitelist" "+Sin,-Sin")]
+ (#'core/validate-symreg-opts (#'core/parse-main-opts test-input)))
+
+ {:headless true
+ :log-level :info
+ :max-leafs 40
+ :use-flamechart false
+ :iterations 200
+ :population 1000
+ :xs nil
+ :ys nil
+ :seed nil
+ :adaptive-mode false
+ :quiet-logs false
+ :use-eval-cache false
+ :scoring-method :mae-max
+ :mutations-whitelist ["+Sin" "-Sin"]
+ :mutations-blacklist nil}))))
+
+
+(deftest cli-mutations-blacklist-test
+ (testing "parses blacklist option with short flag"
+ (is (=
+ (let [test-input '("-t" "-p1000" "-i" "200" "-b" "Derivative,+Sin")]
+ (#'core/validate-symreg-opts (#'core/parse-main-opts test-input)))
+
+ {:headless true
+ :log-level :info
+ :max-leafs 40
+ :use-flamechart false
+ :iterations 200
+ :population 1000
+ :xs nil
+ :ys nil
+ :seed nil
+ :adaptive-mode false
+ :quiet-logs false
+ :use-eval-cache false
+ :scoring-method :mae-max
+ :mutations-whitelist nil
+ :mutations-blacklist ["Derivative" "+Sin"]})))
+
+ (testing "parses blacklist option with long flag"
+ (is (=
+ (let [test-input '("--headless" "--population" "1000" "--iterations" "200"
+ "--mutations-blacklist" "Derivative")]
+ (#'core/validate-symreg-opts (#'core/parse-main-opts test-input)))
+
+ {:headless true
+ :log-level :info
+ :max-leafs 40
+ :use-flamechart false
+ :iterations 200
+ :population 1000
+ :xs nil
+ :ys nil
+ :seed nil
+ :adaptive-mode false
+ :quiet-logs false
+ :use-eval-cache false
+ :scoring-method :mae-max
+ :mutations-whitelist nil
+ :mutations-blacklist ["Derivative"]}))))
+
+
+(deftest cli-mutations-whitelist-and-blacklist-test
+ (testing "parses both whitelist and blacklist together"
+ (is (=
+ (let [test-input '("-t" "-p1000" "-i" "200" "-w" "+Sin,-Sin,+Cos,-Cos" "-b" "+Sin")]
+ (#'core/validate-symreg-opts (#'core/parse-main-opts test-input)))
+
+ {:headless true
+ :log-level :info
+ :max-leafs 40
+ :use-flamechart false
+ :iterations 200
+ :population 1000
+ :xs nil
+ :ys nil
+ :seed nil
+ :adaptive-mode false
+ :quiet-logs false
+ :use-eval-cache false
+ :scoring-method :mae-max
+ :mutations-whitelist ["+Sin" "-Sin" "+Cos" "-Cos"]
+ :mutations-blacklist ["+Sin"]}))))
+
+
+(deftest cli-mutations-whitelist-and-blacklist-test-2
+ (testing "parses both whitelist and blacklist together with adaptive mode and quiet logs"
+ (is (= {:headless true
+ :log-level :info
+ :max-leafs 40
+ :use-flamechart false
+ :iterations 200
+ :population 1000
+ :xs nil
+ :ys nil
+ :seed nil
+ :adaptive-mode true
+ :quiet-logs true
+ :use-eval-cache false
+ :scoring-method :mae-max
+ :mutations-whitelist ["+Sin" "-Sin" "+Cos" "-Cos"]
+ :mutations-blacklist ["+Sin"]}
+ (let [test-input '("-t" "-p1000" "-i" "200" "-w" "+Sin,-Sin,+Cos,-Cos" "-b" "+Sin" "-a" "-q")]
+ (#'core/validate-symreg-opts (#'core/parse-main-opts test-input)))))))
(deftest main-test
diff --git a/test/closyr/find_formula_java_test.clj b/test/closyr/find_formula_java_test.clj
new file mode 100644
index 00000000..c5c5241f
--- /dev/null
+++ b/test/closyr/find_formula_java_test.clj
@@ -0,0 +1,244 @@
+(ns closyr.find-formula-java-test
+ "Tests for the Java FindFormula API that calls into Clojure symbolic regression."
+ (:require
+ [clojure.test :refer :all]
+ [closyr.test-utils :as test-utils])
+ (:import
+ (org.closyr.core
+ FindFormula
+ FindFormula$Config
+ FindFormula$Result
+ FindFormula$Solution)))
+
+
+(use-fixtures :once test-utils/quiet-logging-fixture)
+
+
+(deftest test-find-formula-with-linear-data
+ (testing "Simple linear relationship: y = 2x"
+ (let [xs (double-array [1.0 2.0 3.0 4.0 5.0])
+ ys (double-array [2.0 4.0 6.0 8.0 10.0])
+ config (-> (FindFormula$Config.)
+ (.iterations 5)
+ (.populationSize 30))
+ result (FindFormula/findFormula xs ys config)]
+ (is (instance? FindFormula$Result result))
+ (is (string? (.getFormulaString result)))
+ (is (not (empty? (.getFormulaString result))))
+ (is (number? (.getScore result)))
+ (is (pos? (.getIterationsDone result)))
+ (is (seq (.getAllSolutions result))))))
+
+
+(deftest test-find-formula-with-default-config
+ (testing "Using default configuration"
+ (let [xs (double-array [0.0 1.0 2.0 3.0])
+ ys (double-array [1.0 2.0 5.0 10.0])
+ result (FindFormula/findFormula xs ys)]
+ (is (instance? FindFormula$Result result))
+ (is (= 20 (.getIterationsDone result))) ; default iterations
+ (is (string? (.getFormulaString result))))))
+
+
+(deftest test-result-contains-symja-expr
+ (testing "Result contains a Symja IExpr for symbolic computation"
+ (let [xs (double-array [1.0 2.0 3.0])
+ ys (double-array [3.0 6.0 9.0])
+ config (-> (FindFormula$Config.)
+ (.iterations 3)
+ (.populationSize 20))
+ result (FindFormula/findFormula xs ys config)
+ formula-expr (.getFormulaExpr result)]
+ (is (some? formula-expr) "Formula IExpr should not be null")
+ (is (string? (.toString formula-expr)))
+ (is (not (empty? (.toString formula-expr)))))))
+
+
+(deftest test-all-solutions-sorted-by-score
+ (testing "Solutions are sorted by score (best first)"
+ (let [xs (double-array [1.0 2.0 3.0 4.0])
+ ys (double-array [2.0 4.0 8.0 16.0])
+ config (-> (FindFormula$Config.)
+ (.iterations 3)
+ (.populationSize 30))
+ result (FindFormula/findFormula xs ys config)
+ solutions (.getAllSolutions result)]
+ (is (seq solutions))
+ ;; Verify sorted by score descending (higher/closer to 0 is better)
+ (doseq [[prev curr] (partition 2 1 solutions)]
+ (is (>= (.getScore prev) (.getScore curr))
+ "Solutions should be sorted by score descending"))
+ ;; Best solution should match result's formula
+ (is (= (.getFormulaString result)
+ (.getFormulaString (first solutions))))
+ (is (= (.getScore result)
+ (.getScore (first solutions)))))))
+
+
+(deftest test-null-xs-throws-exception
+ (testing "Null xs array throws IllegalArgumentException"
+ (let [ys (double-array [1.0 2.0 3.0])]
+ (is (thrown-with-msg? IllegalArgumentException #"null"
+ (FindFormula/findFormula nil ys))))))
+
+
+(deftest test-null-ys-throws-exception
+ (testing "Null ys array throws IllegalArgumentException"
+ (let [xs (double-array [1.0 2.0 3.0])]
+ (is (thrown-with-msg? IllegalArgumentException #"null"
+ (FindFormula/findFormula xs nil))))))
+
+
+(deftest test-mismatched-array-lengths-throws-exception
+ (testing "Mismatched array lengths throws IllegalArgumentException"
+ (let [xs (double-array [1.0 2.0 3.0])
+ ys (double-array [1.0 2.0])]
+ (is (thrown-with-msg? IllegalArgumentException #"same length"
+ (FindFormula/findFormula xs ys))))))
+
+
+(deftest test-too-few-data-points-throws-exception
+ (testing "Less than 2 data points throws IllegalArgumentException"
+ (let [xs (double-array [1.0])
+ ys (double-array [2.0])]
+ (is (thrown-with-msg? IllegalArgumentException #"At least 2"
+ (FindFormula/findFormula xs ys))))))
+
+
+(deftest test-config-builder
+ (testing "Config builder works correctly"
+ (let [config (-> (FindFormula$Config.)
+ (.iterations 50)
+ (.populationSize 200)
+ (.maxLeafs 30))]
+ (is (= 50 (.getIterations config)))
+ (is (= 200 (.getPopulationSize config)))
+ (is (= 30 (.getMaxLeafs config))))))
+
+
+(deftest test-result-to-string
+ (testing "Result toString includes expected fields"
+ (let [xs (double-array [1.0 2.0 3.0])
+ ys (double-array [2.0 4.0 6.0])
+ config (-> (FindFormula$Config.)
+ (.iterations 2)
+ (.populationSize 10))
+ result (FindFormula/findFormula xs ys config)
+ str-repr (.toString result)]
+ (is (string? str-repr))
+ (is (.contains str-repr "Result{"))
+ (is (.contains str-repr "formula="))
+ (is (.contains str-repr "score="))
+ (is (.contains str-repr "iterations=")))))
+
+
+(deftest test-solution-to-string
+ (testing "Solution toString includes expected fields"
+ (let [xs (double-array [1.0 2.0 3.0])
+ ys (double-array [1.0 4.0 9.0])
+ config (-> (FindFormula$Config.)
+ (.iterations 2)
+ (.populationSize 10))
+ result (FindFormula/findFormula xs ys config)
+ solution (first (.getAllSolutions result))
+ str-repr (.toString solution)]
+ (is (string? str-repr))
+ (is (.contains str-repr "Solution{"))
+ (is (.contains str-repr "formula="))
+ (is (.contains str-repr "score=")))))
+
+
+(deftest test-multiple-calls-work
+ (testing "Multiple calls to findFormula work correctly"
+ ;; Verify the API works for multiple sequential calls
+ (let [xs (double-array [1.0 2.0])
+ ys (double-array [1.0 2.0])
+ config (-> (FindFormula$Config.)
+ (.iterations 1)
+ (.populationSize 5))
+ result1 (FindFormula/findFormula xs ys config)
+ result2 (FindFormula/findFormula xs ys config)]
+ (is (some? result1))
+ (is (some? result2)))))
+
+
+(deftest test-random-seed-deterministic
+ (testing "Running solver with same random seed produces identical results"
+ (let [xs (double-array [1.0 2.0 3.0 4.0 5.0])
+ ys (double-array [2.0 4.0 6.0 8.0 10.0])
+ seed 42
+ config (-> (FindFormula$Config.)
+ (.iterations 5)
+ (.populationSize 30)
+ (.randomSeed seed))
+ ;; Run solver twice with same seed
+ result1 (FindFormula/findFormula xs ys config)
+ result2 (FindFormula/findFormula xs ys config)]
+ ;; Results should be identical
+ (is (= (.getFormulaString result1) (.getFormulaString result2))
+ "Same seed should produce identical formulas")
+ (is (= (.getScore result1) (.getScore result2))
+ "Same seed should produce identical scores"))))
+
+
+(deftest test-random-seed-config-getter
+ (testing "Config getter for random seed works correctly"
+ (let [config (-> (FindFormula$Config.)
+ (.randomSeed 12345))]
+ (is (= 12345 (.getRandomSeed config))))))
+
+
+(deftest test-mutations-whitelist-config
+ (testing "Config accepts mutations whitelist"
+ (let [config (-> (FindFormula$Config.)
+ (.mutationsWhitelist (into-array String ["+Sin" "-Sin" "+Cos"])))]
+ (is (= ["+Sin" "-Sin" "+Cos"] (vec (.getMutationsWhitelist config))))))
+
+ (testing "Whitelist restricts mutations used"
+ (let [xs (double-array [1.0 2.0 3.0])
+ ys (double-array [2.0 4.0 6.0])
+ config (-> (FindFormula$Config.)
+ (.iterations 2)
+ (.populationSize 10)
+ (.randomSeed 42)
+ (.mutationsWhitelist (into-array String ["+Sin" "-Sin" "+Cos" "-Cos"])))]
+ ;; Should run without error
+ (let [result (FindFormula/findFormula xs ys config)]
+ (is (instance? FindFormula$Result result))
+ (is (some? (.getFormulaString result)))))))
+
+
+(deftest test-mutations-blacklist-config
+ (testing "Config accepts mutations blacklist"
+ (let [config (-> (FindFormula$Config.)
+ (.mutationsBlacklist (into-array String ["Derivative"])))]
+ (is (= ["Derivative"] (vec (.getMutationsBlacklist config))))))
+
+ (testing "Blacklist excludes mutations"
+ (let [xs (double-array [1.0 2.0 3.0])
+ ys (double-array [2.0 4.0 6.0])
+ config (-> (FindFormula$Config.)
+ (.iterations 2)
+ (.populationSize 10)
+ (.randomSeed 42)
+ (.mutationsBlacklist (into-array String ["Derivative" "+Sin"])))]
+ ;; Should run without error
+ (let [result (FindFormula/findFormula xs ys config)]
+ (is (instance? FindFormula$Result result))
+ (is (some? (.getFormulaString result)))))))
+
+
+(deftest test-mutations-whitelist-and-blacklist
+ (testing "Config accepts both whitelist and blacklist"
+ (let [xs (double-array [1.0 2.0 3.0])
+ ys (double-array [2.0 4.0 6.0])
+ config (-> (FindFormula$Config.)
+ (.iterations 2)
+ (.populationSize 10)
+ (.randomSeed 42)
+ (.mutationsWhitelist (into-array String ["+Sin" "-Sin" "+Cos" "-Cos" "*x"]))
+ (.mutationsBlacklist (into-array String ["+Sin"])))]
+ ;; Should run with whitelist - blacklist = 4 mutations
+ (let [result (FindFormula/findFormula xs ys config)]
+ (is (instance? FindFormula$Result result))
+ (is (some? (.getFormulaString result)))))))
diff --git a/test/closyr/ga_test.clj b/test/closyr/ga_test.clj
index 29fcb0e1..d37157bd 100644
--- a/test/closyr/ga_test.clj
+++ b/test/closyr/ga_test.clj
@@ -41,11 +41,11 @@
(let [new-pop (ga/evolve population)
s (reduce + 0.0 (:pop-scores new-pop))]
(when (zero? (rem i 20))
- (println i " pop score: " s))
+ #_(println i " pop score: " s))
(recur new-pop
(if (zero? s)
(do
- (println "Perfect score!")
+ #_(println "Perfect score!")
0)
(dec i)))))))
(count initial-pop)))
diff --git a/test/closyr/ops_common_test.clj b/test/closyr/ops_common_test.clj
index fae2364b..a494c98f 100644
--- a/test/closyr/ops_common_test.clj
+++ b/test/closyr/ops_common_test.clj
@@ -38,6 +38,11 @@
(str (:expr (ops-common/->phenotype x (F/Sin x) nil))))
"Sin(x)")))
+ (testing "with Hold-wrapped expr returns nil"
+ (is (= (let [x (F/Dummy "x")]
+ (ops-common/->phenotype x (F/Hold (F/Sin x)) nil))
+ nil)))
+
(testing "with expr throwing exception"
(is (=
(let [x (F/Dummy "x")]
@@ -74,6 +79,7 @@
(is (instance? IAST parsed))
(is (= (str parsed)
(str (F/Cos x))))))
+
(testing "can parse simple fn str 2"
(let [x (F/Dummy "x")
parsed (.parse (ops-common/new-eval-engine) "x+Cos(x^2)")]
@@ -95,7 +101,8 @@
{:expr "1/5+x"
:simple? true}))
(is (= @ops-common/do-not-simplify-fns*
- {"1/5+Sin(ArcSin(x))" 1}))))))))
+ {"1/5+Sin(ArcSin(x))" 1}))
+ (reset! ops-common/do-not-simplify-fns* {})))))))
(deftest simplify-with-ignore-presets
@@ -107,8 +114,8 @@
{:expr "x"
:simple? true}))
(is (= @ops-common/do-not-simplify-fns*
- {"x" 2})))
- (reset! ops-common/do-not-simplify-fns* {})))
+ {"x" 2}))
+ (reset! ops-common/do-not-simplify-fns* {}))))
(deftest simplify-test
@@ -143,7 +150,8 @@
(F/Plus (F/Cos x))
(F/Plus (F/Sqrt x)))})
:expr str))
- {:expr "1+Sqrt(x)+Cos(x)+Sin(x)+2*(Cos(ArcSin(x))+Tan(ArcSin(x)))"}))))
+ {:expr "1+Sqrt(x)+Cos(x)+Sin(x)+2*(Cos(ArcSin(x))+Tan(ArcSin(x)))"}))
+ (reset! ops-common/do-not-simplify-fns* {})))
(testing "simplify sum"
(let [x (F/Dummy "x")]
diff --git a/test/closyr/ops_eval_test.clj b/test/closyr/ops_eval_test.clj
index 9c1e2c45..ba0a186d 100644
--- a/test/closyr/ops_eval_test.clj
+++ b/test/closyr/ops_eval_test.clj
@@ -122,23 +122,27 @@
:input-xs-count 1})))
[##Inf])))
- (testing "with failing conversion throws exception"
- (is (thrown? Exception
+ (testing "with failing conversion returns infinity"
+ ;; Exceptions during conversion are caught and return infinity
+ (is (=
(with-redefs-fn {#'ops-common/expr->double (fn [_] (throw (Exception. "Test exception")))}
(fn []
(ops-eval/eval-vec-pheno
(ops-common/->phenotype x (F/Subtract F/C1 F/C1D2) nil)
{:input-xs-list (ops-common/exprs->exprs-list (ops-common/doubles->exprs [0.5]))
- :input-xs-count 1}))))))
+ :input-xs-count 1})))
+ [##Inf])))
- (testing "with failing conversion throws exception 2"
- (is (thrown? Exception
+ (testing "with failing constant input conversion returns infinity"
+ ;; Exceptions in result-args->constant-input are caught and return infinity
+ (is (=
(with-redefs-fn {#'ops-eval/result-args->constant-input (fn [_ _ _] (throw (Exception. "Test exception")))}
(fn []
(ops-eval/eval-vec-pheno
(ops-common/->phenotype x (F/Subtract x F/C1D2) nil)
{:input-xs-list (ops-common/exprs->exprs-list (ops-common/doubles->exprs [0.5 1.0]))
- :input-xs-count 1}))))))
+ :input-xs-count 1})))
+ [##Inf])))
(testing "with failing conversion handles error"
(is (=
@@ -301,4 +305,168 @@
(range 320))))))))
+(deftest eval-extended-test
+ (let [x (F/Dummy "x")]
+ (testing "eval-extended returns nil when middle-section is nil"
+ (is (= (with-redefs-fn {#'ops-eval/eval-vec-pheno (fn [_ _] nil)}
+ (fn []
+ (ops-eval/eval-extended
+ (ops-common/->phenotype x (F/Sin x) nil)
+ {:input-xs-list (ops-common/exprs->exprs-list (ops-common/doubles->exprs [1.0 2.0]))
+ :input-xs-count 2}
+ {:x-head [0.5]
+ :x-head-list (ops-common/exprs->exprs-list (ops-common/doubles->exprs [0.5]))
+ :x-tail [3.0]
+ :x-tail-list (ops-common/exprs->exprs-list (ops-common/doubles->exprs [3.0]))})))
+ nil)))
+
+ (testing "eval-extended returns nil when middle-section is empty"
+ (is (= (with-redefs-fn {#'ops-eval/eval-vec-pheno (fn [_ _] [])}
+ (fn []
+ (ops-eval/eval-extended
+ (ops-common/->phenotype x (F/Sin x) nil)
+ {:input-xs-list (ops-common/exprs->exprs-list (ops-common/doubles->exprs [1.0 2.0]))
+ :input-xs-count 2}
+ {:x-head [0.5]
+ :x-head-list (ops-common/exprs->exprs-list (ops-common/doubles->exprs [0.5]))
+ :x-tail [3.0]
+ :x-tail-list (ops-common/exprs->exprs-list (ops-common/doubles->exprs [3.0]))})))
+ nil)))))
+
+(deftest long-running-fns-test
+
+ ;; 2.55+x+(-1.33+1.31736/x^(4351/200000)+x*(9/10+0.89*Log(-3/5+x))-133/100*Log(x))^(101/100)-Log(x)
+ (testing "a known long-running fn"
+ (let [x (F/Dummy "x")
+ p (ops-common/->phenotype
+ x
+ (F/Plus (F/num 2.55)
+ (F/Plus x
+ (F/Subtract
+ (F/Power
+ (F/Plus
+ (F/num -1.33)
+ (F/Subtract
+ (F/Plus
+ (F/Divide
+ (F/num 1.31726)
+ (F/Power x (F/num (float (/ 4351 200000)))))
+ (F/Times x
+ (F/Plus
+ (F/num 0.9)
+ (F/Times (F/num 0.89)
+ (F/Log (F/Plus (F/num -0.6) x))))))
+ (F/Times (F/num 1.33)
+ (F/Log x))))
+ (F/num 1.01))
+ (F/Log x))))
+ (ops-common/new-util))]
+
+ (is (= [3.6198954469921762
+ 5.30675637508254
+ 8.028748008549133
+ 11.334902089392536
+ 15.031610064000752
+ 19.017953836452868
+ 23.233095713241145
+ 27.63672344397211
+ 32.200313077602296
+ 36.90268798682677
+ 41.72753958418945
+ 46.66194111908503
+ 51.695405636638846
+ 56.81926140603571
+ 62.02622223443477
+ 67.3100825662299
+ 72.66549536889605
+ 78.0878066313582
+ 83.57292960227778
+ 89.11724756826268
+ 94.71753754430598
+ 100.37090956267996
+ 106.07475778318434
+ 111.82672069109606
+ 117.62464837198287
+ 123.46657536234513
+ 129.3506979405347
+ 135.2753549883469
+ 141.23901174985158
+ 147.24024596055037
+ 153.27773593065334
+ 159.35025025080688
+ 165.45663885380483
+ 171.5958252165628
+ 177.76679952647245
+ 183.9686126677778
+ 190.20037090875272
+ 196.4612311906346
+ 202.75039693558395
+ 209.06711430420148
+ 215.41066884398796
+ 221.7803824790561
+ 228.17561079878726
+ 234.59574060926056
+ 241.04018771640287
+ 247.50839491410636
+ 253.99983015418005
+ 260.5139848780605
+ 267.05037249280963
+ 273.60852697614
+ 280.188001597104
+ 286.78836774070965
+ 293.4092138261243
+ 300.0501443093376
+ 306.71077876220386
+ 313.39075102068966
+ 320.08970839594997
+ 326.8073109425465
+ 333.5432307787265
+ 340.2971514542159
+ 347.068767361446
+ 353.85778318654803
+ 360.66391339681456
+ 367.4868817616474
+ 374.3264209043056
+ 381.1822718820126
+ 388.0541837922185
+ 394.94191340300966
+ 401.8452248058449
+ 408.76388908895416
+ 415.69768402988905
+ 422.64639380583674
+ 429.60980872043854
+ 436.58772494594757
+ 443.57994427966935
+ 450.58627391370453
+ 457.6065262171033
+ 464.6405185296001
+ 471.6880729661739
+ 478.74901623172957
+ 485.82317944525664
+ 492.91039797286317
+ 500.01051126913455
+ 507.12336272630375
+ 514.2487995307574
+ 521.3866725264379
+ 528.536836084731
+ 535.6991479804582
+ 542.8734692736228
+ 550.0596641965745
+ 557.2576000462905
+ 564.4671470814827
+ 571.6881784242667
+ 578.9205699661395
+ 586.1642002780333
+ 593.4189505242285
+ 600.6847043799195
+ 607.9613479522391
+ 615.2487697045685]
+ (mapv
+ ops-common/expr->double
+ (ops-eval/eval-phenotype-on-expr-args
+ p
+ (ops-common/exprs->exprs-list (ops-common/doubles->exprs (vec (range 1.0 100.0 1.0)))))))
+ "Can eval long running fn"))))
+
+
(comment (run-tests 'closyr.ops-eval-test))
diff --git a/test/closyr/ops_initialize_test.clj b/test/closyr/ops_initialize_test.clj
new file mode 100644
index 00000000..3531a0e8
--- /dev/null
+++ b/test/closyr/ops_initialize_test.clj
@@ -0,0 +1,79 @@
+(ns closyr.ops-initialize-test
+ "Tests for ops/initialize namespace, including mutation filtering"
+ (:require
+ [clojure.test :refer :all]
+ [closyr.ops.initialize :as ops-init]
+ [closyr.test-utils :as test-utils]))
+
+
+(use-fixtures :once test-utils/quiet-logging-fixture)
+
+
+(deftest test-mutation-labels
+ (testing "mutation-labels returns a vector of strings"
+ (let [labels (ops-init/mutation-labels)]
+ (is (vector? labels))
+ (is (every? string? labels))
+ (is (pos? (count labels)))
+ ;; Check some known mutation labels exist
+ (is (some #(= "+Sin" %) labels))
+ (is (some #(= "*x" %) labels))
+ (is (some #(= "Derivative" %) labels)))))
+
+
+(deftest test-filter-mutations-whitelist
+ (testing "whitelist filters to only specified mutations"
+ (let [whitelist ["+Sin" "-Sin" "+Cos"]
+ filtered (ops-init/filter-mutations {:whitelist whitelist})]
+ (is (= 3 (count filtered)))
+ (is (= (set whitelist) (set (map :label filtered))))))
+
+ (testing "whitelist with single mutation"
+ (let [filtered (ops-init/filter-mutations {:whitelist ["Derivative"]})]
+ (is (= 1 (count filtered)))
+ (is (= "Derivative" (:label (first filtered)))))))
+
+
+(deftest test-filter-mutations-blacklist
+ (testing "blacklist excludes specified mutations"
+ (let [all-count (count (ops-init/initial-mutations))
+ blacklist ["Derivative" "+Sin" "-Sin"]
+ filtered (ops-init/filter-mutations {:blacklist blacklist})]
+ (is (= (- all-count 3) (count filtered)))
+ (is (not-any? #(contains? (set blacklist) (:label %)) filtered))))
+
+ (testing "blacklist with single mutation"
+ (let [all-count (count (ops-init/initial-mutations))
+ filtered (ops-init/filter-mutations {:blacklist ["Derivative"]})]
+ (is (= (dec all-count) (count filtered)))
+ (is (not-any? #(= "Derivative" (:label %)) filtered)))))
+
+
+(deftest test-filter-mutations-whitelist-and-blacklist
+ (testing "whitelist applied first, then blacklist"
+ (let [whitelist ["+Sin" "-Sin" "+Cos" "-Cos"]
+ blacklist ["+Sin" "+Cos"]
+ filtered (ops-init/filter-mutations {:whitelist whitelist
+ :blacklist blacklist})]
+ ;; Should have only -Sin and -Cos (whitelist minus blacklist)
+ (is (= 2 (count filtered)))
+ (is (= #{"-Sin" "-Cos"} (set (map :label filtered)))))))
+
+
+(deftest test-filter-mutations-empty-result-throws
+ (testing "throws exception when filtering leaves no mutations"
+ (is (thrown-with-msg? IllegalArgumentException
+ #"No mutations remaining"
+ (ops-init/filter-mutations {:whitelist ["nonexistent"]})))
+
+ (is (thrown-with-msg? IllegalArgumentException
+ #"No mutations remaining"
+ (ops-init/filter-mutations {:whitelist ["+Sin"]
+ :blacklist ["+Sin"]})))))
+
+
+(deftest test-filter-mutations-no-filters
+ (testing "no filters returns all mutations"
+ (let [all-muts (ops-init/initial-mutations)
+ filtered (ops-init/filter-mutations {})]
+ (is (= (count all-muts) (count filtered))))))
diff --git a/test/closyr/ops_modify_test.clj b/test/closyr/ops_modify_test.clj
index c0c19348..2477db93 100644
--- a/test/closyr/ops_modify_test.clj
+++ b/test/closyr/ops_modify_test.clj
@@ -7,6 +7,7 @@
[closyr.ops.modify :as ops-modify]
[closyr.util.prng :as prng])
(:import
+ (java.util UUID)
(org.matheclipse.core.expression
F)
(org.matheclipse.core.interfaces
@@ -399,6 +400,44 @@
:expr (F/Plus x (F/Times x (F/Cos (F/Subtract x F/C1D2))))})
nil)))))))
+ ;; Test for fix: when ->phenotype returns nil in the "record new last op" branch,
+ ;; crossover should return nil (not an incomplete map like {:last-op "cros:plus"})
+ (with-redefs-fn {#'prng/rand-int (fn [maxv] (dec maxv))
+ #'prng/rand-nth (fn [coll] (first coll))
+ #'ops-common/->phenotype (fn [_ _ _] nil)}
+ (fn []
+ (with-redefs [ops-modify/crossover-sampler [:plus]]
+ (let [x (F/Dummy "x")]
+ (testing "Crossover returns nil when ->phenotype fails (new expr branch)"
+ (is (= (ops-modify/crossover
+ 100
+ {:sym x
+ :expr (F/Cos x)}
+ {:sym x
+ :expr (F/Sin x)})
+ nil)))))))
+
+ ;; Test for fix: when ->phenotype returns nil in the "keep last op" branch (discount-mod? true),
+ ;; crossover should return the original phenotype p unchanged
+ (with-redefs-fn {#'prng/rand-int (fn [maxv] (dec maxv))
+ #'prng/rand-nth (fn [coll] (first coll))
+ #'ops-common/->phenotype (fn [_ _ _] nil)}
+ (fn []
+ (with-redefs [ops-modify/crossover-sampler [:plus]
+ ;; Force discount-mod? to be true
+ ops-modify/check-modification-result (fn [_ _ _] [false true])]
+ (let [x (F/Dummy "x")
+ test-uuid (UUID/randomUUID)
+ original-pheno {:sym x :expr (F/Cos x) :id test-uuid}]
+ (testing "Crossover returns original phenotype when ->phenotype fails (discount branch)"
+ (is (= (ops-modify/crossover
+ 100
+ original-pheno
+ {:sym x
+ :expr (F/Sin x)
+ :id (UUID/randomUUID)})
+ original-pheno)))))))
+
(with-redefs-fn {#'prng/rand-int (fn [maxv] (dec maxv))
#'prng/rand-nth (fn [coll] (last coll))}
(fn []
@@ -460,6 +499,7 @@
"x^4*Cos(1/2-x)^4"))))))))
+;; initial fn that's getting modified is: x + cos(x)/sqrt(x) + x*sin(x-0.5) - 1
(def all-mods-applied-on-fn-expected
[[:modify-fn
"Derivative"
@@ -598,10 +638,10 @@
"-1-x+Cos(x)/Sqrt(-x)+x*Sin(1/2+x)"]
[:modify-leafs
"1.1*x"
- "-1+1.1*x+(0.9534625892455922*Cos(1.1*x))/Sqrt(x)-1.1*x*Sin(1/2-1.1*x)"]
+ "-1+1.1*x+(0.953463*Cos(1.1*x))/Sqrt(x)-1.1*x*Sin(1/2-1.1*x)"]
[:modify-leafs
"0.9*x"
- "-1+0.9*x+(1.0540925533894598*Cos(0.9*x))/Sqrt(x)-0.9*x*Sin(1/2-0.9*x)"]
+ "-1+0.9*x+(1.05409*Cos(0.9*x))/Sqrt(x)-0.9*x*Sin(1/2-0.9*x)"]
[:modify-leafs
"sin(x)"
"-1+Cos(Sin(x))/Sqrt(Sin(x))+Sin(x)-Sin(x)*Sin(1/2-Sin(x))"]
@@ -739,16 +779,16 @@
"1-x+Cos(x)/Sqrt(x)-x*Sin(1/2+x)"]
[:modify-branches
"b*1.1"
- "1.1*(-1+x+(1.3310000000000004*Cos(x))/Sqrt(x)-1.2100000000000002*x*Sin(1.1*(1/2-1.1*x)))"]
+ "1.1*(-1+x+(1.331*Cos(x))/Sqrt(x)-1.21*x*Sin(1.1*(1/2-1.1*x)))"]
[:modify-branches
"b*0.9"
- "0.9*(-1+x+(0.7290000000000001*Cos(x))/Sqrt(x)-0.81*x*Sin(0.9*(1/2-0.9*x)))"]
+ "0.9*(-1+x+(0.729*Cos(x))/Sqrt(x)-0.81*x*Sin(0.9*(1/2-0.9*x)))"]
[:modify-branches
"b+0.1"
- "-0.7000000000000001+x+(0.1+1/Sqrt(x))*(0.1+Cos(x))-x*(0.1+Sin(0.7-x))"]
+ "-0.7+x+(0.1+1/Sqrt(x))*(0.1+Cos(x))-x*(0.1+Sin(0.7-x))"]
[:modify-branches
"b-0.1"
- "-1.3000000000000003+x+(-0.1+1/Sqrt(x))*(-0.1+Cos(x))+x*(0.1-Sin(0.30000000000000004-x))"]])
+ "-1.3+x+(-0.1+1/Sqrt(x))*(-0.1+Cos(x))+x*(0.1-Sin(0.3-x))"]])
(deftest mutations-test
@@ -756,7 +796,7 @@
#'prng/rand (fn [] 0.0)}
(fn []
(let [x (F/Dummy "x")
- ;; x + cos(x) + x*sin(x-0.5) - 1
+ ;; x + cos(x)/sqrt(x) + x*sin(x-0.5) - 1
test-expr (.minus
(.plus x (.plus
(.divide (F/Cos x) (F/Sqrt x))
diff --git a/test/closyr/ops_test.clj b/test/closyr/ops_test.clj
index fb4d9f19..e821589c 100644
--- a/test/closyr/ops_test.clj
+++ b/test/closyr/ops_test.clj
@@ -25,7 +25,21 @@
{:max-leafs ops/default-max-leafs}
(let [x (F/Dummy "x")]
(ops-common/->phenotype x (F/Subtract (F/Times x x) F/C1D2) nil)))
- -3.0000147)))
+ -3.0000064668272377)))
+
+ (testing "eval score on Hold-wrapped expr returns min-score"
+ (is (=
+ (ops/score-fn {:input-ys-vec [0 1 2]
+ :input-xs-list (ops-common/exprs->exprs-list
+ (ops-common/doubles->exprs [0.5 1.0 2.0]))
+ :input-xs-count 3}
+ {:max-leafs ops/default-max-leafs}
+ (let [x (F/Dummy "x")]
+ ;; Manually create a phenotype with Hold-wrapped expr
+ {:sym x
+ :id (random-uuid)
+ :expr (F/Hold (F/Sin x))}))
+ ops/min-score)))
(testing "eval score on too big fn"
(is (=
@@ -68,16 +82,17 @@
(deftest compute-score-from-actuals-and-expecteds-test
(testing "simple inputs"
(let [x (F/Dummy "x")]
- (is (= (#'ops/compute-score-from-actuals-and-expecteds
+ (is (= -1.5000050968429093
+ (#'ops/compute-score-from-actuals-and-expecteds
(ops-common/->phenotype x (F/Plus (F/Sin x) F/C1D2) nil)
[0.5]
[1.0]
- 10)
- -1.500015))))
+ 10)))))
(testing "throws exception"
(let [x (F/Dummy "x")]
- (is (= (with-redefs-fn
+ (is (= ops/min-score
+ (with-redefs-fn
{#'ops/compute-residual (fn [_ _] (throw (Exception. "Test Exception")))}
(fn []
@@ -85,31 +100,121 @@
(ops-common/->phenotype x (F/Plus (F/Sin x) F/C1D2) nil)
[0.5]
[1.0]
- 10)))
-
- ops/min-score))))
+ 10)))))))
(testing "without length deduction"
(with-redefs-fn {#'ops/length-deduction (fn [score leafs] score)}
(fn []
(let [x (F/Dummy "x")]
- (is (= (#'ops/compute-score-from-actuals-and-expecteds
+ (is (= 0.0
+ (#'ops/compute-score-from-actuals-and-expecteds
(ops-common/->phenotype x (F/Plus (F/Sin x) F/C1D2) nil)
[0.5]
[1.0]
- 10)
- 0.0))))))
+ 10)))))))
(testing "without length deduction 2"
(with-redefs-fn {#'ops/length-deduction (fn [score leafs] 0)}
(fn []
(let [x (F/Dummy "x")]
- (is (= (#'ops/compute-score-from-actuals-and-expecteds
+ (is (= -1.5
+ (#'ops/compute-score-from-actuals-and-expecteds
(ops-common/->phenotype x (F/Plus (F/Sin x) F/C1D2) nil)
[0.5]
[1.0]
- 10)
- -1.5)))))))
+ 10)))))))
+
+ (testing "log-cosh scoring method"
+ (with-redefs-fn {#'ops/length-deduction (fn [score leafs] 0)}
+ (fn []
+ (let [x (F/Dummy "x")
+ ys-arr (double-array [1.0 2.0 3.0])
+ ;; Perfect predictions - log-cosh of 0 residuals = 0
+ perfect-score (#'ops/compute-score-from-actuals-and-expecteds
+ (ops-common/->phenotype x x nil)
+ [1.0 2.0 3.0]
+ [1.0 2.0 3.0]
+ 5
+ ys-arr
+ :log-cosh)]
+ ;; Perfect fit should give score of 0 (or very close)
+ (is (< (abs perfect-score) 0.0001)))
+ (let [x (F/Dummy "x")
+ ys-arr (double-array [1.0 2.0 3.0])
+ ;; Predictions off by 1 each
+ imperfect-score (#'ops/compute-score-from-actuals-and-expecteds
+ (ops-common/->phenotype x x nil)
+ [2.0 3.0 4.0]
+ [1.0 2.0 3.0]
+ 5
+ ys-arr
+ :log-cosh)]
+ ;; Score should be negative (log-cosh(1) ≈ 0.433)
+ (is (< imperfect-score 0))
+ (is (> imperfect-score -1.0))))))
+
+ (testing "r-squared scoring method"
+ (with-redefs-fn {#'ops/length-deduction (fn [score leafs] 0)}
+ (fn []
+ (let [x (F/Dummy "x")
+ ys-arr (double-array [1.0 2.0 3.0])
+ ;; Perfect predictions - (R² - 1) = 0
+ perfect-score (#'ops/compute-score-from-actuals-and-expecteds
+ (ops-common/->phenotype x x nil)
+ [1.0 2.0 3.0]
+ [1.0 2.0 3.0]
+ 5
+ ys-arr
+ :r-squared)]
+ (is (= perfect-score 0.0)))
+ (let [x (F/Dummy "x")
+ ys-arr (double-array [1.0 2.0 3.0])
+ ;; Predictions = mean (2.0) - R² = 0, so (R² - 1) = -1
+ mean-score (#'ops/compute-score-from-actuals-and-expecteds
+ (ops-common/->phenotype x x nil)
+ [2.0 2.0 2.0]
+ [1.0 2.0 3.0]
+ 5
+ ys-arr
+ :r-squared)]
+ (is (< (abs (- mean-score -1.0)) 0.0001)))
+ (let [x (F/Dummy "x")
+ ys-arr (double-array [1.0 2.0 3.0])
+ ;; Predictions worse than mean - R² < 0, so (R² - 1) < -1
+ bad-score (#'ops/compute-score-from-actuals-and-expecteds
+ (ops-common/->phenotype x x nil)
+ [10.0 10.0 10.0]
+ [1.0 2.0 3.0]
+ 5
+ ys-arr
+ :r-squared)]
+ (is (< bad-score -1.0))))))
+
+ (testing "scoring method via dynamic var"
+ (with-redefs-fn {#'ops/length-deduction (fn [score leafs] 0)}
+ (fn []
+ (let [x (F/Dummy "x")
+ ys-arr (double-array [1.0 2.0 3.0])
+ ;; Default MAE score
+ mae-score (#'ops/compute-score-from-actuals-and-expecteds
+ (ops-common/->phenotype x x nil)
+ [1.0 2.0 3.0]
+ [1.0 2.0 3.0]
+ 5
+ ys-arr
+ :mae-max)
+ ;; R² score via binding
+ r2-score (binding [ops/*scoring-method* :r-squared]
+ (#'ops/compute-score-from-actuals-and-expecteds
+ (ops-common/->phenotype x x nil)
+ [1.0 2.0 3.0]
+ [1.0 2.0 3.0]
+ 5
+ ys-arr
+ ops/*scoring-method*))]
+ ;; Perfect fit: MAE gives 0, R² gives 0 (both use 0 as perfect score)
+ (is (= mae-score 0.0))
+ (is (= r2-score 0.0)))))))
(deftest mutation-fn-test
@@ -201,3 +306,223 @@
(testing "invalid input 2"
(is (= (#'ops/compute-residual ##Inf ##Inf)
ops/max-resid))))
+
+
+(deftest eval-cache-test
+ (testing "cache starts empty"
+ (ops/clear-eval-cache!)
+ (let [stats (ops/eval-cache-stats)]
+ (is (= 0 (:size stats)))))
+
+ (testing "cache is populated when enabled"
+ (ops/clear-eval-cache!)
+ (binding [ops/*use-eval-cache* true]
+ (let [run-args {:input-ys-vec [0 1 2]
+ :input-xs-list (ops-common/exprs->exprs-list
+ (ops-common/doubles->exprs [0.5 1.0 2.0]))
+ :input-xs-count 3}
+ run-config {:max-leafs ops/default-max-leafs}
+ x (F/Dummy "x")
+ pheno (ops-common/->phenotype x (F/Subtract (F/Times x x) F/C1D2) nil)]
+ ;; First call should miss
+ (ops/score-fn run-args run-config pheno)
+ (let [stats (ops/eval-cache-stats)]
+ (is (= 1 (:size stats)))
+ (is (= 1 (:misses stats)))
+ (is (= 0 (:hits stats))))
+ ;; Second call with same expr should hit
+ (ops/score-fn run-args run-config pheno)
+ (let [stats (ops/eval-cache-stats)]
+ (is (= 1 (:size stats)))
+ (is (= 1 (:misses stats)))
+ (is (= 1 (:hits stats)))))))
+
+ (testing "cache is not populated when disabled"
+ (ops/clear-eval-cache!)
+ (binding [ops/*use-eval-cache* false]
+ (let [run-args {:input-ys-vec [0 1 2]
+ :input-xs-list (ops-common/exprs->exprs-list
+ (ops-common/doubles->exprs [0.5 1.0 2.0]))
+ :input-xs-count 3}
+ run-config {:max-leafs ops/default-max-leafs}
+ x (F/Dummy "x")
+ pheno (ops-common/->phenotype x (F/Subtract (F/Times x x) F/C1D2) nil)]
+ (ops/score-fn run-args run-config pheno)
+ (ops/score-fn run-args run-config pheno)
+ (let [stats (ops/eval-cache-stats)]
+ (is (= 0 (:size stats)))))))
+
+ (testing "cache returns same score for same expression"
+ (ops/clear-eval-cache!)
+ (binding [ops/*use-eval-cache* true]
+ (let [run-args {:input-ys-vec [0 1 2]
+ :input-xs-list (ops-common/exprs->exprs-list
+ (ops-common/doubles->exprs [0.5 1.0 2.0]))
+ :input-xs-count 3}
+ run-config {:max-leafs ops/default-max-leafs}
+ x (F/Dummy "x")
+ pheno (ops-common/->phenotype x (F/Subtract (F/Times x x) F/C1D2) nil)
+ score1 (ops/score-fn run-args run-config pheno)
+ score2 (ops/score-fn run-args run-config pheno)]
+ (is (= score1 score2))
+ ;; Cache key is [expr-str scoring-method] to prevent cross-contamination
+ (is (= {["-1/2+x^2" :mae-max :tiebreaker] -3.0000064668272377}
+ @ops/eval-cache*)))))
+
+ (testing "different scoring methods have separate cache entries"
+ (ops/clear-eval-cache!)
+ (binding [ops/*use-eval-cache* true]
+ ;; Use data where x^2 is NOT a perfect fit so different scoring methods produce different scores
+ (let [run-args {:input-ys-vec [0.0 2.0 5.0] ; Not a perfect fit for x^2
+ :input-ys-arr (double-array [0.0 2.0 5.0])
+ :input-xs-list (ops-common/exprs->exprs-list
+ (ops-common/doubles->exprs [0.0 1.0 2.0]))
+ :input-xs-count 3}
+ run-config-mae {:max-leafs ops/default-max-leafs :scoring-method :mae-max}
+ run-config-r2 {:max-leafs ops/default-max-leafs :scoring-method :r-squared}
+ x (F/Dummy "x")
+ pheno (ops-common/->phenotype x (F/Times x x) nil) ; x^2 gives [0, 1, 4], not [0, 2, 5]
+ ;; Score with MAE method
+ score-mae (ops/score-fn run-args run-config-mae pheno)
+ ;; Score with R-squared method - should NOT hit cache
+ score-r2 (ops/score-fn run-args run-config-r2 pheno)]
+ ;; Different scoring methods should produce different scores for imperfect fit
+ (is (not= score-mae score-r2))
+ ;; Cache should have 2 entries (one per scoring method)
+ (is (= 2 (:size (ops/eval-cache-stats))))
+ (is (= 2 (:misses (ops/eval-cache-stats))))
+ (is (= 0 (:hits (ops/eval-cache-stats))))
+ ;; Now call again with same methods - should hit cache
+ (ops/score-fn run-args run-config-mae pheno)
+ (ops/score-fn run-args run-config-r2 pheno)
+ (is (= 2 (:hits (ops/eval-cache-stats)))))))
+
+ (testing "clear-eval-cache! resets cache"
+ (ops/clear-eval-cache!)
+ (binding [ops/*use-eval-cache* true]
+ (let [run-args {:input-ys-vec [0 1 2]
+ :input-xs-list (ops-common/exprs->exprs-list
+ (ops-common/doubles->exprs [0.5 1.0 2.0]))
+ :input-xs-count 3}
+ run-config {:max-leafs ops/default-max-leafs}
+ x (F/Dummy "x")
+ pheno (ops-common/->phenotype x (F/Subtract (F/Times x x) F/C1D2) nil)]
+ (ops/score-fn run-args run-config pheno)
+ (is (= 1 (:size (ops/eval-cache-stats))))
+ (ops/clear-eval-cache!)
+ (is (= 0 (:size (ops/eval-cache-stats))))))))
+
+
+(deftest simplicity-bias-test
+ (testing "simplicity-bias :none returns zero deduction on perfect fit"
+ (binding [ops/*simplicity-bias* :none]
+ (let [x (F/Dummy "x")
+ ys-arr (double-array [1.0 2.0 3.0])
+ ;; Perfect fit with identity function
+ score (#'ops/compute-score-from-actuals-and-expecteds
+ (ops-common/->phenotype x x nil)
+ [1.0 2.0 3.0]
+ [1.0 2.0 3.0]
+ 5
+ ys-arr
+ :mae-max)]
+ ;; With :none bias and perfect fit, score should be exactly 0
+ (is (= score 0.0)))))
+
+ (testing "simplicity-bias :tiebreaker applies tiny deduction on imperfect fit"
+ (binding [ops/*simplicity-bias* :tiebreaker]
+ (let [x (F/Dummy "x")
+ ;; Use a complex expression that does NOT perfectly fit the data
+ ;; x^2 + sin(x) evaluated at [1, 2, 3] gives roughly [1.84, 4.91, 9.14]
+ ;; We'll use ys = [2, 5, 10] for a slight mismatch
+ complex-expr (F/Plus (F/Times x x) (F/Sin x))
+ ys-arr (double-array [2.0 5.0 10.0])
+ score (#'ops/compute-score-from-actuals-and-expecteds
+ (ops-common/->phenotype x complex-expr nil)
+ [1.8414709848078965 4.909297426825682 9.141120008059867] ; actual f(x) values
+ [2.0 5.0 10.0]
+ 5
+ ys-arr
+ :mae-max)]
+ ;; Score should be negative (error + deduction)
+ (is (< score 0))
+ ;; The deduction component should be tiny relative to the error
+ (is (> score -5.0)))))
+
+ (testing "simplicity-bias levels have increasing deductions"
+ (let [x (F/Dummy "x")
+ ;; Use a complex expression with imperfect fit to get non-zero base score
+ ;; x^2 evaluated at [0, 1, 2] gives [0, 1, 4], we use ys = [0.5, 1.5, 4.5] for error
+ complex-expr (F/Times x x)
+ actuals [0.0 1.0 4.0]
+ expected [0.5 1.5 4.5]
+ ys-arr (double-array expected)
+ ;; Calculate scores with different bias levels
+ score-none (binding [ops/*simplicity-bias* :none]
+ (#'ops/compute-score-from-actuals-and-expecteds
+ (ops-common/->phenotype x complex-expr nil)
+ actuals
+ expected
+ 5
+ ys-arr
+ :mae-max))
+ score-tiebreaker (binding [ops/*simplicity-bias* :tiebreaker]
+ (#'ops/compute-score-from-actuals-and-expecteds
+ (ops-common/->phenotype x complex-expr nil)
+ actuals
+ expected
+ 5
+ ys-arr
+ :mae-max))
+ score-light (binding [ops/*simplicity-bias* :light]
+ (#'ops/compute-score-from-actuals-and-expecteds
+ (ops-common/->phenotype x complex-expr nil)
+ actuals
+ expected
+ 5
+ ys-arr
+ :mae-max))
+ score-strong (binding [ops/*simplicity-bias* :strong]
+ (#'ops/compute-score-from-actuals-and-expecteds
+ (ops-common/->phenotype x complex-expr nil)
+ actuals
+ expected
+ 5
+ ys-arr
+ :mae-max))]
+ ;; All scores should be negative (there's error)
+ (is (< score-none 0))
+ ;; Each level should have lower (more negative) score due to larger deduction
+ ;; The deduction is proportional to abs(score), so with non-zero error we'll see differences
+ (is (>= score-none score-tiebreaker))
+ (is (>= score-tiebreaker score-light))
+ (is (>= score-light score-strong))
+ ;; At least some differences should exist (strong should be noticeably lower)
+ (is (> score-none score-strong))))
+
+ (testing "simplicity-bias default is :tiebreaker"
+ (is (= ops/*simplicity-bias* :tiebreaker)))
+
+ (testing "simplicity-bias-config has expected keys and structure"
+ (let [config @#'ops/simplicity-bias-config]
+ (is (contains? config :none))
+ (is (contains? config :tiebreaker))
+ (is (contains? config :light))
+ (is (contains? config :strong))
+ ;; Each level should have :multiplier and :cap
+ (doseq [level [:none :tiebreaker :light :strong]]
+ (is (contains? (get config level) :multiplier))
+ (is (contains? (get config level) :cap)))
+ ;; :none should have 0 multiplier and 0 cap
+ (is (= 0.0 (:multiplier (:none config))))
+ (is (= 0.0 (:cap (:none config))))
+ ;; Multipliers should have increasing values
+ (is (< (:multiplier (:tiebreaker config))
+ (:multiplier (:light config))))
+ (is (< (:multiplier (:light config))
+ (:multiplier (:strong config))))
+ ;; Caps should have increasing values
+ (is (< (:cap (:tiebreaker config))
+ (:cap (:light config))))
+ (is (< (:cap (:light config))
+ (:cap (:strong config)))))))
diff --git a/test/closyr/seeded_evolution_test.clj b/test/closyr/seeded_evolution_test.clj
new file mode 100644
index 00000000..b8a5f535
--- /dev/null
+++ b/test/closyr/seeded_evolution_test.clj
@@ -0,0 +1,556 @@
+(ns closyr.seeded-evolution-test
+ "Tests for seeding GA evolution with parsed formula strings.
+
+ This test namespace exercises the full pipeline of:
+ 1. Parsing formula strings to phenotypes
+ 2. Scoring parsed phenotypes
+ 3. Mutating parsed phenotypes
+ 4. Running mini GA evolution with seeded phenotypes
+
+ IMPORTANT: Errors are logged at ERROR level to expose real issues."
+ (:require
+ [clojure.test :refer :all]
+ [closyr.ga :as ga]
+ [closyr.ops :as ops]
+ [closyr.ops.common :as ops-common]
+ [closyr.ops.eval :as ops-eval]
+ [closyr.ops.initialize :as ops-init]
+ [closyr.util.log :as log]))
+
+
+;; Log at ERROR level during tests to expose real issues
+(defn error-logging-fixture
+ "Test fixture that sets log level to ERROR during tests to expose real issues."
+ [f]
+ (log/set-log-level! :error)
+ (f))
+
+
+(use-fixtures :once error-logging-fixture)
+
+
+;;; ============================================================================
+;;; Use functions from ops-init
+;;; ============================================================================
+
+
+;; Alias for convenience in tests
+(def parse-formula->phenotype ops-init/parse-formula->phenotype)
+(def seeded-phenotypes ops-init/seeded-phenotypes)
+
+
+;;; ============================================================================
+;;; Test Helpers
+;;; ============================================================================
+
+
+(def test-xs
+ "Test x values"
+ [0.0 0.5 1.0 1.5 2.0])
+
+
+(def test-ys
+ "Test y values (sin(x) for testing)"
+ (mapv #(Math/sin %) test-xs))
+
+
+(defn- make-test-run-args
+ "Create run-args for testing with simple x/y data"
+ []
+ (let [xs-exprs (ops-common/doubles->exprs test-xs)]
+ {:input-xs-list (ops-common/exprs->exprs-list xs-exprs)
+ :input-xs-count (count test-xs)
+ :input-xs-vec test-xs
+ :input-ys-vec test-ys
+ :input-ys-arr (double-array test-ys)}))
+
+
+(defn- make-test-run-config
+ "Create run-config for testing"
+ []
+ {:max-leafs 40
+ :iters 5})
+
+
+;;; ============================================================================
+;;; Tests - Formula Parsing
+;;; ============================================================================
+
+
+(deftest test-parse-simple-formula
+ (testing "parsing simple formulas produces valid phenotypes"
+ (let [pheno (parse-formula->phenotype "x")]
+ (is (some? pheno) "Should parse 'x'")
+ (is (= ops-common/sym-x (:sym pheno)) "sym should be sym-x")
+ (is (some? (:expr pheno)) "Should have expr")
+ (is (some? (:util pheno)) "Should have util"))
+
+ (let [pheno (parse-formula->phenotype "Sin(x)")]
+ (is (some? pheno) "Should parse 'Sin(x)'"))
+
+ (let [pheno (parse-formula->phenotype "x^2 + 2*x + 1")]
+ (is (some? pheno) "Should parse polynomial"))))
+
+
+(deftest test-parsed-symbol-is-sym-x
+ (testing "parsed expressions use sym-x, not parser's x"
+ (let [pheno (parse-formula->phenotype "x + 1")
+ expr (:expr pheno)
+ expr-str (str expr)]
+ ;; The expression should contain our sym-x, which should work with evaluation
+ (is (some? pheno))
+ ;; Most importantly: the :sym field should be sym-x
+ (is (= ops-common/sym-x (:sym pheno))))))
+
+
+(deftest test-parse-complex-formulas
+ (testing "parsing complex formulas from typical solver output"
+ (doseq [formula ["Sin(x) + Cos(x)"
+ "x^2 - 3*x + 1"
+ "Log(x + 1)"
+ "Exp(-x^2)"
+ "x/2 + Sin(x)/3"]]
+ (let [pheno (parse-formula->phenotype formula)]
+ (is (some? pheno) (str "Should parse: " formula))))))
+
+
+(deftest test-parse-invalid-formulas
+ (testing "empty formula returns nil"
+ ;; Note: Symja parser is very permissive, so most "invalid" strings will
+ ;; still parse to something. We just test that empty strings are rejected.
+ (is (nil? (parse-formula->phenotype "")))))
+
+
+;;; ============================================================================
+;;; Tests - Scoring Parsed Phenotypes
+;;; ============================================================================
+
+
+(deftest test-score-parsed-phenotype
+ (testing "parsed phenotypes can be scored without errors"
+ (let [run-args (make-test-run-args)
+ run-config (make-test-run-config)
+ score-fn (partial ops/score-fn run-args run-config)]
+
+ ;; Test scoring "Sin(x)" on sin(x) data - should have good score
+ (let [pheno (parse-formula->phenotype "Sin(x)")]
+ (is (some? pheno) "Should parse Sin(x)")
+ (let [score (score-fn pheno)]
+ (is (number? score) "Score should be a number")
+ (is (not (Double/isNaN score)) "Score should not be NaN")
+ ;; Score for correct formula should be close to 0 (perfect)
+ (is (> score -0.1) "Sin(x) should score well on sin(x) data")))
+
+ ;; Test scoring other formulas - they should all produce valid scores
+ (doseq [formula ["x" "x^2" "Cos(x)" "x + 1"]]
+ (let [pheno (parse-formula->phenotype formula)]
+ (is (some? pheno) (str "Should parse: " formula))
+ (let [score (score-fn pheno)]
+ (is (number? score) (str "Score for " formula " should be a number"))
+ (is (not (Double/isNaN score)) (str "Score for " formula " should not be NaN"))))))))
+
+
+;;; ============================================================================
+;;; Tests - Mutating Parsed Phenotypes
+;;; ============================================================================
+
+
+(deftest test-mutate-parsed-phenotype
+ (testing "parsed phenotypes can be mutated without errors"
+ (let [run-config (make-test-run-config)
+ mutations (ops-init/initial-mutations)
+ mutation-fn (partial ops/mutation-fn run-config mutations)
+ pheno1 (parse-formula->phenotype "Sin(x)")]
+
+ (is (some? pheno1) "Should parse Sin(x)")
+
+ ;; Try multiple mutations to ensure they work
+ (dotimes [_ 10]
+ (let [mutated (mutation-fn pheno1 pheno1)]
+ (is (some? mutated) "Mutation should produce a phenotype")
+ (is (some? (:expr mutated)) "Mutated should have expr")
+ (is (= ops-common/sym-x (:sym mutated)) "Mutated should have sym-x"))))))
+
+
+;;; ============================================================================
+;;; Tests - Mini GA Evolution
+;;; ============================================================================
+
+
+(deftest test-mini-evolution-with-seeded-phenotypes
+ (testing "GA evolution works with seeded phenotypes"
+ (let [run-args (make-test-run-args)
+ run-config (make-test-run-config)
+ mutations (ops-init/initial-mutations)
+
+ ;; Create seeded population
+ seed-formulas ["Sin(x)" "x" "Cos(x)" "x^2"]
+ initial-pop (seeded-phenotypes seed-formulas 0.2 10)
+
+ _ (is (= 10 (count initial-pop)) "Should have 10 phenotypes")
+
+ ;; Setup GA
+ score-fn (partial ops/score-fn run-args run-config)
+ mutation-fn (partial ops/mutation-fn run-config mutations)
+ crossover-fn (partial ops/crossover-fn run-config mutations)
+
+ ga-state (ga/initialize initial-pop score-fn mutation-fn crossover-fn)]
+
+ ;; Run 3 iterations of evolution
+ (let [final-state (loop [state ga-state
+ i 3]
+ (if (zero? i)
+ state
+ (let [evolved (ga/evolve state)]
+ (is (some? (:pop evolved)) "Should have population after evolve")
+ (is (pos? (count (:pop evolved))) "Population should not be empty")
+ ;; Check all phenotypes have valid structure
+ (doseq [p (:pop evolved)]
+ (is (some? (:expr p)) "Each phenotype should have expr")
+ (is (= ops-common/sym-x (:sym p)) "Each phenotype should have sym-x"))
+ (recur evolved (dec i)))))]
+
+ (is (some? final-state) "Evolution should complete")
+ (is (pos? (count (:pop final-state))) "Final population should not be empty")))))
+
+
+(deftest test-evolution-with-all-seeded
+ (testing "GA evolution works when 100% seeded (no fresh phenotypes)"
+ (let [run-args (make-test-run-args)
+ run-config (make-test-run-config)
+ mutations (ops-init/initial-mutations)
+
+ ;; Create 100% seeded population
+ seed-formulas ["Sin(x)" "Cos(x)" "x + 1"]
+ initial-pop (seeded-phenotypes seed-formulas 0.0 6) ; 0% fresh
+
+ _ (is (= 6 (count initial-pop)) "Should have 6 phenotypes")
+
+ ;; All should be from seeds
+ _ (doseq [p initial-pop]
+ (is (some? (:expr p)) "Each phenotype should have expr")
+ (is (= ops-common/sym-x (:sym p)) "Each phenotype should have sym-x"))
+
+ score-fn (partial ops/score-fn run-args run-config)
+ mutation-fn (partial ops/mutation-fn run-config mutations)
+ crossover-fn (partial ops/crossover-fn run-config mutations)
+
+ ga-state (ga/initialize initial-pop score-fn mutation-fn crossover-fn)]
+
+ ;; Run evolution
+ (let [evolved1 (ga/evolve ga-state)
+ evolved2 (ga/evolve evolved1)]
+ (is (pos? (count (:pop evolved2))) "Should have population after 2 iterations")))))
+
+
+#_(deftest test-evolution-preserves-valid-phenotypes
+ (testing "evolution doesn't produce corrupted phenotypes"
+ (let [run-args (make-test-run-args)
+ run-config (make-test-run-config)
+ mutations (ops-init/initial-mutations)
+
+ seed-formulas ["Sin(x) + x" "x^2 - 1"]
+ initial-pop (seeded-phenotypes seed-formulas 0.0 4)
+
+ score-fn (partial ops/score-fn run-args run-config)
+ mutation-fn (partial ops/mutation-fn run-config mutations)
+ crossover-fn (partial ops/crossover-fn run-config mutations)
+
+ ga-state (ga/initialize initial-pop score-fn mutation-fn crossover-fn)]
+
+ ;; Run 5 iterations and check for corruption
+ (loop [state ga-state
+ i 5]
+ (when (pos? i)
+ (let [evolved (ga/evolve state)]
+ ;; Check each phenotype for corruption
+ (doseq [p (:pop evolved)]
+ (is (some? (:expr p)) "Phenotype should have expr")
+ (is (= ops-common/sym-x (:sym p)) "Phenotype sym should be sym-x")
+ ;; The expression string should not contain "Function(" or "Hold("
+ (let [expr-str (str (:expr p))]
+ (is (not (.contains expr-str "Function("))
+ (str "Expression should not contain Function(: " expr-str))
+ (is (not (.contains expr-str "Hold("))
+ (str "Expression should not contain Hold(: " expr-str))))
+ (recur evolved (dec i))))))))
+
+
+#_(deftest test-longer-evolution-with-realistic-formulas
+ (testing "longer evolution with formulas similar to solver output"
+ (let [;; More data points like real solver
+ xs (mapv #(* % 0.1) (range 20))
+ ys (mapv #(+ (Math/sin %) (* 0.5 %)) xs)
+ xs-exprs (ops-common/doubles->exprs xs)
+ run-args {:input-xs-list (ops-common/exprs->exprs-list xs-exprs)
+ :input-xs-count (count xs)
+ :input-xs-vec xs
+ :input-ys-vec ys
+ :input-ys-arr (double-array ys)}
+ run-config {:max-leafs 40 :iters 20}
+ mutations (ops-init/initial-mutations)
+
+ ;; Use formulas that look like solver output
+ seed-formulas ["Sin(x)+x/2"
+ "x+Sin(x)"
+ "0.5*x+Sin(x)"
+ "Sin(x)+0.5*x"
+ "x/2+Sin(x)"]
+ initial-pop (seeded-phenotypes seed-formulas 0.2 20)
+
+ _ (log/debug "Created initial pop of" (count initial-pop) "phenotypes")
+ _ (doseq [p (take 3 initial-pop)]
+ (log/debug " Initial phenotype:" (str (:expr p)) "sym:" (:sym p)))
+
+ score-fn (partial ops/score-fn run-args run-config)
+ mutation-fn (partial ops/mutation-fn run-config mutations)
+ crossover-fn (partial ops/crossover-fn run-config mutations)
+
+ ga-state (ga/initialize initial-pop score-fn mutation-fn crossover-fn)]
+
+ ;; Run 20 iterations like a real solver would
+ (loop [state ga-state
+ i 20]
+ (when (pos? i)
+ (let [evolved (ga/evolve state)]
+ ;; Check for corruption
+ (doseq [p (:pop evolved)]
+ (let [expr-str (str (:expr p))]
+ (when (.contains expr-str "Function(")
+ (log/debug "CORRUPTION DETECTED at iteration" (- 20 i) ":" expr-str))
+ (is (not (.contains expr-str "Function("))
+ (str "Expression corrupted with Function(: " expr-str))
+ (is (not (.contains expr-str "Hold("))
+ (str "Expression corrupted with Hold(: " expr-str))))
+ (recur evolved (dec i)))))
+
+ ;; Verify we completed without throwing
+ (is true "Evolution completed without throwing"))))
+
+
+#_(deftest test-evolution-with-complex-solver-formulas
+ (testing "evolution with complex formulas that might come from solver"
+ (let [xs (mapv #(* % 0.1) (range 20))
+ ys (mapv #(Math/sin %) xs)
+ xs-exprs (ops-common/doubles->exprs xs)
+ run-args {:input-xs-list (ops-common/exprs->exprs-list xs-exprs)
+ :input-xs-count (count xs)
+ :input-xs-vec xs
+ :input-ys-vec ys
+ :input-ys-arr (double-array ys)}
+ run-config {:max-leafs 40 :iters 10}
+ mutations (ops-init/initial-mutations)
+
+ ;; Complex formulas with fractions and nested operations
+ seed-formulas ["1/2*Sin(x)+1/10"
+ "Sin(x)-1/100"
+ "1/10+Sin(x)+1/100"
+ "0.538165*Sin(x)"
+ "Sin(x)^(1/2)"
+ "Log(1+x)"
+ "Exp(-x/10)"
+ "Cos(x)+Sin(x)/2"
+ "x^2/10+Sin(x)"
+ "1/(1+x^2)"]
+ initial-pop (seeded-phenotypes seed-formulas 0.0 10)]
+
+ (log/debug "Testing complex formulas:")
+ (doseq [p initial-pop]
+ (log/debug " Parsed:" (str (:expr p))))
+
+ ;; Just verify they all parsed correctly
+ (is (= 10 (count initial-pop)) "All formulas should parse")
+ (doseq [p initial-pop]
+ (is (= ops-common/sym-x (:sym p)) "All should have sym-x"))
+
+ ;; Run a few iterations
+ (let [score-fn (partial ops/score-fn run-args run-config)
+ mutation-fn (partial ops/mutation-fn run-config mutations)
+ crossover-fn (partial ops/crossover-fn run-config mutations)
+ ga-state (ga/initialize initial-pop score-fn mutation-fn crossover-fn)]
+
+ (loop [state ga-state
+ i 10]
+ (when (pos? i)
+ (let [evolved (ga/evolve state)]
+ (doseq [p (:pop evolved)]
+ (let [expr-str (str (:expr p))]
+ (when (or (.contains expr-str "Function(")
+ (.contains expr-str "Hold("))
+ (log/debug "CORRUPTION at iter" (- 10 i) ":" expr-str))
+ (is (not (.contains expr-str "Function(")) expr-str)
+ (is (not (.contains expr-str "Hold(")) expr-str)))
+ (recur evolved (dec i))))))))
+
+
+#_(deftest test-intensive-evolution-like-real-solver
+ (testing "intensive evolution matching real solver settings (100 pop, 50+ iters)"
+ (let [;; Use 20 points like the real case
+ xs (mapv #(* % 0.1) (range 20))
+ ys (mapv #(Math/sin %) xs)
+ xs-exprs (ops-common/doubles->exprs xs)
+ run-args {:input-xs-list (ops-common/exprs->exprs-list xs-exprs)
+ :input-xs-count (count xs)
+ :input-xs-vec xs
+ :input-ys-vec ys
+ :input-ys-arr (double-array ys)}
+ run-config {:max-leafs 40 :iters 50}
+ mutations (ops-init/initial-mutations)
+
+ ;; 10 seed formulas, expanded to 100 pop with 20% fresh
+ seed-formulas ["Sin(x)"
+ "0.538165*Sin(x)"
+ "Sin(x)+0.1"
+ "x+Sin(x)"
+ "Sin(x)*Cos(x)"
+ "Sin(x)^2"
+ "Sin(2*x)/2"
+ "Sin(x)+Sin(2*x)/10"
+ "Sin(x)-x/100"
+ "Sin(x)+x^2/100"]
+ initial-pop (seeded-phenotypes seed-formulas 0.2 100)
+
+ _ (log/debug "Intensive test: pop=" (count initial-pop))
+
+ score-fn (partial ops/score-fn run-args run-config)
+ mutation-fn (partial ops/mutation-fn run-config mutations)
+ crossover-fn (partial ops/crossover-fn run-config mutations)
+
+ ga-state (ga/initialize initial-pop score-fn mutation-fn crossover-fn)]
+
+ (is (= 100 (count initial-pop)) "Should have 100 phenotypes")
+
+ ;; Run 50 iterations
+ (loop [state ga-state
+ i 50]
+ (when (pos? i)
+ (let [evolved (ga/evolve state)]
+ ;; Check for corruption
+ (doseq [p (:pop evolved)]
+ (let [expr-str (str (:expr p))]
+ (when (or (.contains expr-str "Function(")
+ (.contains expr-str "Hold("))
+ (log/debug "CORRUPTION at iter" (- 50 i) "in pop:" expr-str)
+ (throw (ex-info "Corruption detected!" {:expr expr-str :iter (- 50 i)})))
+ (is (not (.contains expr-str "Function(")) expr-str)
+ (is (not (.contains expr-str "Hold(")) expr-str)))
+ (recur evolved (dec i)))))
+
+ (is true "Intensive evolution completed without corruption")))))
+
+
+(deftest test-parse-does-not-introduce-function-wrapper
+ (testing "parsed phenotypes should not contain Function( in their expression"
+ (let [formulas ["Sin(x)"
+ "x^2"
+ "0.538165*Sin(x)"
+ "1/2+x"
+ "Cos(x)+Sin(x)/2"]]
+ (doseq [f formulas]
+ (let [p (parse-formula->phenotype f)
+ expr-str (str (:expr p))]
+ (log/debug "Parsed formula" f "=> expr:" expr-str)
+ (is (not (.contains expr-str "Function("))
+ (str "Formula " f " should not have Function( in expr: " expr-str))
+ (is (not (.contains expr-str "Hold("))
+ (str "Formula " f " should not have Hold( in expr: " expr-str)))))))
+
+
+(deftest test-phenotype-evaluation-does-not-corrupt
+ (testing "evaluating a parsed phenotype does not corrupt it"
+ (let [xs [0.0 0.5 1.0 1.5 2.0]
+ xs-exprs (ops-common/doubles->exprs xs)
+ run-args {:input-xs-list (ops-common/exprs->exprs-list xs-exprs)
+ :input-xs-count (count xs)}
+ p (parse-formula->phenotype "Sin(x)")]
+
+ (log/debug "Before eval, expr:" (str (:expr p)))
+
+ ;; Evaluate the phenotype
+ (let [result (closyr.ops.eval/eval-vec-pheno p run-args)]
+ (log/debug "After eval, result:" result)
+ (log/debug "After eval, expr:" (str (:expr p))))
+
+ ;; The phenotype's expr should not be mutated by evaluation
+ (is (not (.contains (str (:expr p)) "Function("))
+ "Expr should not contain Function( after eval")
+ (is (not (.contains (str (:expr p)) "Hold("))
+ "Expr should not contain Hold( after eval"))))
+
+
+(deftest test-score-function-does-not-corrupt-phenotype
+ (testing "scoring a parsed phenotype does not corrupt its expr"
+ (let [xs [0.0 0.5 1.0 1.5 2.0]
+ ys (mapv #(Math/sin %) xs)
+ xs-exprs (ops-common/doubles->exprs xs)
+ run-args {:input-xs-list (ops-common/exprs->exprs-list xs-exprs)
+ :input-xs-count (count xs)
+ :input-xs-vec xs
+ :input-ys-vec ys
+ :input-ys-arr (double-array ys)}
+ run-config {:max-leafs 40}
+ p (parse-formula->phenotype "Sin(x)")]
+
+ (log/debug "Before score, expr:" (str (:expr p)))
+
+ ;; Score the phenotype
+ (let [score (ops/score-fn run-args run-config p)]
+ (log/debug "Score:" score)
+ (log/debug "After score, expr:" (str (:expr p))))
+
+ ;; The phenotype's expr should not be mutated by scoring
+ (is (not (.contains (str (:expr p)) "Function("))
+ "Expr should not contain Function( after scoring")
+ (is (not (.contains (str (:expr p)) "Hold("))
+ "Expr should not contain Hold( after scoring"))))
+
+
+;;; ============================================================================
+;;; Tests - Corrupted Formula Patterns (from real errors)
+;;; ============================================================================
+
+
+(deftest test-corrupted-formula-patterns-are-handled
+ (testing "formulas containing corruption patterns are handled gracefully"
+ ;; These patterns were seen in real errors and should not crash the parser
+ (let [corrupted-formulas ["Hold(Function({x},0.538165<>.6842,0.7895,0.8947,1.0}])"
+ "Function({x}, Sin(x))"
+ "{x} + 1"
+ "<>"
+ "List(1,2,3)"
+ "Hold(x)"]]
+ (doseq [formula corrupted-formulas]
+ ;; These should either parse to nil or parse to something that doesn't crash
+ ;; The key is they shouldn't throw exceptions during parsing
+ (let [result (try
+ (parse-formula->phenotype formula)
+ (catch Exception e
+ (log/debug "Exception parsing corrupted formula:" formula "-" (.getMessage e))
+ :exception))]
+ ;; Parsing corrupted formulas might return nil or a phenotype,
+ ;; but should NOT throw exceptions
+ (is (not= :exception result)
+ (str "Parsing corrupted formula should not throw: " formula)))))))
+
+
+(deftest test-valid-formulas-accepted
+ (testing "normal valid formulas are accepted and parse correctly"
+ (let [valid-formulas ["Sin(x)"
+ "x^2 + 2*x + 1"
+ "0.538165*Sin(x)"
+ "Cos(x) + Sin(x)/2"
+ "Log(1+x)"
+ "Exp(-x^2)"]]
+ (doseq [formula valid-formulas]
+ (let [pheno (parse-formula->phenotype formula)]
+ (is (some? pheno) (str "Valid formula should parse: " formula))
+ (is (= ops-common/sym-x (:sym pheno)) (str "Should have sym-x: " formula))
+ (is (some? (:expr pheno)) (str "Should have expr: " formula)))))))
+
+
+;;; ============================================================================
+;;; Run tests
+;;; ============================================================================
+
+(comment
+ (run-tests))
diff --git a/test/closyr/symbolic_regression_test.clj b/test/closyr/symbolic_regression_test.clj
index 3d0664b4..73710cce 100644
--- a/test/closyr/symbolic_regression_test.clj
+++ b/test/closyr/symbolic_regression_test.clj
@@ -5,85 +5,107 @@
[clojure.test :refer :all]
[closyr.ops :as ops]
[closyr.ops.common :as ops-common]
+ [closyr.ops.eval :as ops-eval]
[closyr.ops.initialize :as ops-init]
[closyr.symbolic-regression :as symreg]
+ [closyr.test-utils :as test-utils]
[closyr.util.spec :as specs]
[malli.core :as m])
(:import
(java.awt
- GraphicsEnvironment)))
+ GraphicsEnvironment)
+ (org.matheclipse.core.interfaces
+ IExpr)))
+(use-fixtures :once test-utils/quiet-logging-fixture)
+
(alter-var-root #'symreg/*is-testing* (constantly true))
(deftest end-iters-if-solution-found-test
(testing "not solved"
(is (=
- (#'symreg/next-iters 10 [-10.0])
- 9)))
+ 9
+ (#'symreg/next-iters 10 [-10.0]))))
(testing "an exact solution"
(is (=
- (#'symreg/next-iters 10 [0.0])
- 0))))
+ 0
+ (#'symreg/next-iters 10 [0.0])))))
(deftest can-run-from-cli-args
(testing "args from CLI are passed along correctly to implementation"
(let [args* (atom nil)]
(binding [ops/*print-top-n* 1]
- (is (=
- (dissoc
- (with-redefs-fn {#'symreg/run-ga-iterations-using-record
- (fn [run-config run-args]
- (reset! args*
- [(dissoc run-config :initial-muts :initial-phenos :input-xs-exprs :input-ys-exprs)
- (dissoc run-args :extended-domain-args :initial-phenos :input-xs-list)])
- {:iters-done 123
- :final-population {:pop []
- :score-fn #()
- :pop-scores []
- :mutation-fn #()
- :crossover-fn #()}
- :next-step :stop})
- #'symreg/config->log-steps (fn [_ _] 200)}
- (fn []
- (symreg/run-app-from-cli-args
- {:iterations 20
- :population 20
- :headless true
- :xs [0 1 2]
- :ys [1 4 19]
- :use-flamechart true
- :max-leafs 20})))
- :final-population)
- {:iters-done 123
- :next-step :stop}))
-
- (is (= @args*
- [{:iters 20
+ (is (= {:iters-done 123
+ :next-step :stop}
+ (dissoc
+ (with-redefs-fn {#'symreg/run-solver-ga-iterations
+ (fn [run-config run-args]
+ (reset! args*
+ [(dissoc run-config :initial-muts :initial-phenos :input-xs-exprs :input-ys-exprs)
+ (dissoc run-args :extended-domain-args :initial-phenos :input-xs-list :input-ys-arr)])
+ {:iters-done 123
+ :final-population {:pop []
+ :score-fn #()
+ :pop-scores []
+ :mutation-fn #()
+ :crossover-fn #()}
+ :next-step :stop})
+ #'symreg/config->log-steps (fn [_ _] 200)}
+ (fn []
+ (symreg/run-app-from-cli-args
+ {:iterations 20
+ :population 20
+ :headless true
+ :xs [0 1 2]
+ :ys [1 4 19]
+ :use-flamechart true
+ :max-leafs 20
+ :seed 123})))
+ :final-population)))
+
+ (is (= [{:iters 20
:log-steps 200
:max-leafs 20
:use-gui? false
+ :random-seed 123
+ :adaptive-mode nil
+ :quiet-logs nil
+ :use-eval-cache nil
+ :scoring-method :mae-max
:use-flamechart true}
- {:input-iters 20
- :input-phenos-count nil
- :input-xs-count 3
- :input-xs-vec [0.0 1.0 2.0]
- :input-ys-vec [1.0 4.0 19.0]
- :max-leafs 20}])))))
+ {:input-iters 20
+ :input-phenos-count nil
+ :random-seed 123
+ :adaptive-mode nil
+ :quiet-logs nil
+ :use-eval-cache nil
+ :simplicity-bias nil
+ :scoring-method nil
+ :input-xs-count 3
+ :input-xs-vec [0.0 1.0 2.0]
+ :input-ys-vec [1.0 4.0 19.0]
+ :max-leafs 20
+ :mutations-blacklist nil
+ :log-steps nil}]
+ @args*)
+ "Arguments should be consistent with inputs"))))
(testing "args from CLI are passed along correctly to implementation, and when none are passed we use defaults"
(let [args* (atom nil)]
(binding [ops/*print-top-n* 1]
(is (=
+ {:iters-done 123
+ :next-step :stop}
(dissoc
- (with-redefs-fn {#'symreg/run-ga-iterations-using-record
+ (with-redefs-fn {#'symreg/run-solver-ga-iterations
(fn [run-config run-args]
(reset! args*
[(dissoc run-config :initial-muts :initial-phenos :input-xs-exprs :input-ys-exprs)
- (dissoc run-args :extended-domain-args :initial-phenos :input-xs-list)])
+ (dissoc run-args :extended-domain-args :initial-phenos :input-xs-list :input-ys-arr)])
{:iters-done 123
:final-population {:pop []
:score-fn #()
@@ -97,22 +119,34 @@
{:population 30
:iterations 20
:headless true})))
- :final-population)
- {:iters-done 123
- :next-step :stop}))
+ :final-population)))
- (is (= @args*
- [{:iters 20
+ (is (= [{:iters 20
:log-steps 200
:max-leafs 40
:use-gui? false
+ :random-seed nil
+ :adaptive-mode nil
+ :quiet-logs nil
+ :use-eval-cache nil
+ :scoring-method :mae-max
:use-flamechart nil}
- {:input-iters 20
- :input-phenos-count nil
- :input-xs-count 50
- :input-xs-vec [0.0 0.20943951023931953 0.41887902047863906 0.6283185307179586 0.8377580409572781 1.0471975511965976 1.2566370614359172 1.4660765716752369 1.6755160819145563 1.8849555921538759 2.0943951023931953 2.3038346126325147 2.5132741228718345 2.7227136331111543 2.9321531433504737 3.141592653589793 3.3510321638291125 3.560471674068432 3.7699111843077517 3.979350694547071 4.1887902047863905 4.39822971502571 4.607669225265029 4.81710873550435 5.026548245743669 5.235987755982989 5.445427266222309 5.654866776461628 5.8643062867009474 6.073745796940266 6.283185307179586 6.492624817418906 6.702064327658225 6.911503837897546 7.120943348136864 7.3303828583761845 7.5398223686155035 7.749261878854823 7.958701389094142 8.168140899333462 8.377580409572781 8.587019919812102 8.79645943005142 9.00589894029074 9.215338450530059 9.42477796076938 9.6342174710087 9.843656981248019 10.053096491487338 10.262536001726657]
- :input-ys-vec [0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0]
- :max-leafs nil}]))))))
+ {:input-iters 20
+ :input-phenos-count nil
+ :random-seed nil
+ :adaptive-mode nil
+ :quiet-logs nil
+ :use-eval-cache nil
+ :scoring-method nil
+ :simplicity-bias nil
+ :input-xs-count 50
+ :input-xs-vec [0.0 0.20943951023931953 0.41887902047863906 0.6283185307179586 0.8377580409572781 1.0471975511965976 1.2566370614359172 1.4660765716752369 1.6755160819145563 1.8849555921538759 2.0943951023931953 2.3038346126325147 2.5132741228718345 2.7227136331111543 2.9321531433504737 3.141592653589793 3.3510321638291125 3.560471674068432 3.7699111843077517 3.979350694547071 4.1887902047863905 4.39822971502571 4.607669225265029 4.81710873550435 5.026548245743669 5.235987755982989 5.445427266222309 5.654866776461628 5.8643062867009474 6.073745796940266 6.283185307179586 6.492624817418906 6.702064327658225 6.911503837897546 7.120943348136864 7.3303828583761845 7.5398223686155035 7.749261878854823 7.958701389094142 8.168140899333462 8.377580409572781 8.587019919812102 8.79645943005142 9.00589894029074 9.215338450530059 9.42477796076938 9.6342174710087 9.843656981248019 10.053096491487338 10.262536001726657]
+ :input-ys-vec [0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0]
+ :max-leafs nil
+ :mutations-blacklist nil
+ :log-steps nil}]
+ @args*)
+ "Arguments should be consistent with inputs")))))
(deftest can-run-experiment
@@ -123,32 +157,40 @@
(:final-population
(with-redefs-fn {#'symreg/config->log-steps (fn [_ _] 10)}
(fn []
- (symreg/run-app-without-gui [1 2 3] [6 12 99]))))))
+ (symreg/run-find-formula
+ {:initial-phenos (ops-init/initial-phenotypes 100)
+ :initial-muts (ops-init/initial-mutations)
+ :iters 20
+ :use-gui? false
+ :use-flamechart false
+ :input-xs-exprs (ops-common/doubles->exprs [1 2 3])
+ :input-ys-exprs (ops-common/doubles->exprs [6 12 99])}))))))
100)))
(testing "with gui launcher"
- (let [res (with-redefs-fn {#'symreg/run-solver (fn [args] args)}
+ (let [res (with-redefs-fn {#'symreg/run-find-formula (fn [args] args)}
(fn []
(#'symreg/run-app-with-gui)))]
- (is (= (dissoc res :initial-phenos :initial-muts :input-xs-exprs :input-ys-exprs)
- {:iters 100
+ (is (= {:iters 100
:max-leafs 40
:use-flamechart false
- :use-gui? true}))
- (is (= (count (:initial-phenos res))
- 50))
- (is (= (count (:initial-muts res))
- 96))
- (is (= (count (:input-xs-exprs res))
- 50))
- (is (= (count (:input-ys-exprs res))
- 50))))
+ :use-gui? true}
+ (dissoc res :initial-phenos :initial-muts :input-xs-exprs :input-ys-exprs)))
+ (is (= 50
+ (count (:initial-phenos res))))
+ (is (= 96
+ (count (:initial-muts res))))
+ (is (= 50
+ (count (:input-xs-exprs res))))
+ (is (= 50
+ (count (:input-ys-exprs res))))))
(testing "with provided data"
+ (reset! symreg/sim-input-args* {})
(with-redefs-fn {#'symreg/config->log-steps (fn [_ _] 10)}
(fn []
(let [{:keys [final-population next-step iters-done]}
- (symreg/run-solver
+ (symreg/run-find-formula
{:input-phenos-count 100
:initial-muts (ops-init/initial-mutations)
:iters 5
@@ -163,50 +205,79 @@
(/ i 10.0)
(Math/cos (* Math/PI (/ i 15.0))))))
ops-common/doubles->exprs)})]
- (is (= (count (:pop final-population))
- 100))
+ (is (= 100
+ (count (:pop final-population))))
- (is (= iters-done
- 5))
+ (is (= 5
+ iters-done))
- (is (= (set (keys @symreg/sim-input-args*))
- #{:input-xs-exprs
+ (is (= #{:input-xs-exprs
:input-xs-vec
- :input-ys-vec}))
-
- (reset! symreg/sim-input-args* {})))))
+ :input-ys-vec}
+ (set (keys @symreg/sim-input-args*))))))))
- (testing "with provided data using record"
+ (testing "with provided data and random seed"
+ (reset! symreg/sim-input-args* {})
(with-redefs-fn {#'symreg/config->log-steps (fn [_ _] 10)}
(fn []
(let [{:keys [final-population next-step iters-done]}
- (symreg/solve
- (symreg/map->SymbolicRegressionSolver
- {:input-phenos-count 100
- :initial-muts (ops-init/initial-mutations)
- :iters 5
- :use-gui? false
- :input-xs-exprs (->> (range 50)
- (map (fn [i] (* Math/PI (/ i 15.0))))
- ops-common/doubles->exprs)
- :input-ys-exprs (->> (range 50)
- (map (fn [i]
- (+ 2.0
- (/ i 10.0)
- (Math/cos (* Math/PI (/ i 15.0))))))
- ops-common/doubles->exprs)}))]
- (is (= (count (:pop final-population))
- 100))
-
- (is (= iters-done
- 5))
-
- (is (= (set (keys @symreg/sim-input-args*))
- #{:input-xs-exprs
+ (symreg/run-find-formula
+ {:input-phenos-count 100
+ :initial-muts (ops-init/initial-mutations)
+ :iters 5
+ :use-gui? false
+ :use-flamechart false
+ :random-seed 1234
+ :input-xs-exprs (->> (range 50)
+ (map (fn [i] (* Math/PI (/ i 15.0))))
+ ops-common/doubles->exprs)
+ :input-ys-exprs (->> (range 50)
+ (map (fn [i]
+ (+ 2.0
+ (/ i 10.0)
+ (Math/cos (* Math/PI (/ i 15.0))))))
+ ops-common/doubles->exprs)})]
+ (is (= 100
+ (count (:pop final-population))))
+
+ (is (= 5
+ iters-done))
+
+ (is (= #{:input-xs-exprs
:input-xs-vec
- :input-ys-vec}))
+ :input-ys-vec}
+ (set (keys @symreg/sim-input-args*))))))))
- (reset! symreg/sim-input-args* {})))))))
+ (testing "with provided data as map"
+ (reset! symreg/sim-input-args* {})
+ (with-redefs-fn {#'symreg/config->log-steps (fn [_ _] 10)}
+ (fn []
+ (let [{:keys [final-population next-step iters-done]}
+ (symreg/run-find-formula
+ {:input-phenos-count 100
+ :initial-muts (ops-init/initial-mutations)
+ :iters 5
+ :use-gui? false
+ :use-flamechart false
+ :input-xs-exprs (->> (range 50)
+ (map (fn [i] (* Math/PI (/ i 15.0))))
+ ops-common/doubles->exprs)
+ :input-ys-exprs (->> (range 50)
+ (map (fn [i]
+ (+ 2.0
+ (/ i 10.0)
+ (Math/cos (* Math/PI (/ i 15.0))))))
+ ops-common/doubles->exprs)})]
+ (is (= 100
+ (count (:pop final-population))))
+
+ (is (= 5
+ iters-done))
+
+ (is (= #{:input-xs-exprs
+ :input-xs-vec
+ :input-ys-vec}
+ (set (keys @symreg/sim-input-args*))))))))))
#_(deftest can-run-experiment-gui:start-stop
@@ -227,22 +298,289 @@
(is (put! symreg/*sim->gui-chan* :next))
true)]
- (symreg/run-solver
+ (symreg/run-find-formula
{:initial-phenos (ops-init/initial-phenotypes 20)
:initial-muts (ops-init/initial-mutations)
:input-xs-exprs symreg/example-input-xs-exprs
:input-ys-exprs symreg/example-input-ys-exprs
:iters 20
- :use-gui? true})
+ :use-gui? true
+ :use-flamechart false})
(is (= (log-steps (fn [_ _] 10)}
+ (fn []
+ (let [{:keys [final-population next-step iters-done] :as resp}
+ (symreg/run-find-formula
+ {:input-phenos-count 10
+ :initial-muts (ops-init/initial-mutations)
+ :iters 5
+ :use-gui? false
+ :use-flamechart false
+ :random-seed 123
+ :input-xs-exprs (->> (range 50)
+ (map (fn [i] (* Math/PI (/ i 15.0))))
+ ops-common/doubles->exprs)
+ :input-ys-exprs (->> (range 50)
+ (map (fn [i]
+ (+ 2.0
+ (/ i 10.0)
+ (Math/cos (* Math/PI (/ i 15.0))))))
+ ops-common/doubles->exprs)})]
+
+ (is (=
+ '(-2086020.9864992178
+ -1040014.0760937533
+ -243.28664816600795
+ -190.5004535058019
+ -12.807688931991098)
+ (sort (:pop-scores (:final-population resp)))))
+
+ (is (= 10
+ (count (:pop final-population))))
+
+ (is (= 5
+ iters-done))
+
+ (is (= #{:input-xs-exprs
+ :input-xs-vec
+ :input-ys-vec}
+ (set (keys @symreg/sim-input-args*)))))))))
+
+
+(deftest deterministic-experiments-scoring-methods
+ (let [run-config {:input-phenos-count 10
+ :initial-muts (ops-init/initial-mutations)
+ :iters 5
+ :use-gui? false
+ :use-flamechart false
+ :random-seed 456
+ :input-xs-exprs (->> (range 20)
+ (map (fn [i] (* 0.5 i)))
+ ops-common/doubles->exprs)
+ :input-ys-exprs (->> (range 20)
+ (map (fn [i] (+ (* 2 i) 3)))
+ ops-common/doubles->exprs)}]
+
+ (testing "mae-max scoring method (default)"
+ (reset! symreg/sim-input-args* {})
+ (with-redefs-fn {#'symreg/config->log-steps (fn [_ _] 10)}
+ (fn []
+ (binding [ops/*scoring-method* :mae-max]
+ (let [{:keys [final-population iters-done] :as resp}
+ (symreg/run-find-formula run-config)
+ sorted-scores (sort (:pop-scores final-population))]
+ ;; MAE scores are negative (closer to 0 is better)
+ (is (every? neg? sorted-scores))
+ (is (= 10 (count (:pop final-population))))
+ (is (= 5 iters-done))
+ ;; Best score should be better (less negative) than worst
+ (is (> (last sorted-scores) (first sorted-scores))))))))
+
+ (testing "log-cosh scoring method"
+ (reset! symreg/sim-input-args* {})
+ (with-redefs-fn {#'symreg/config->log-steps (fn [_ _] 10)}
+ (fn []
+ (binding [ops/*scoring-method* :log-cosh]
+ (let [{:keys [final-population iters-done] :as resp}
+ (symreg/run-find-formula run-config)
+ sorted-scores (sort (:pop-scores final-population))]
+ ;; Log-cosh scores are negative (closer to 0 is better)
+ (is (every? neg? sorted-scores))
+ (is (= 10 (count (:pop final-population))))
+ (is (= 5 iters-done))
+ ;; Best score should be better (less negative) than worst
+ (is (> (last sorted-scores) (first sorted-scores))))))))
+
+ (testing "r-squared scoring method"
+ (reset! symreg/sim-input-args* {})
+ (with-redefs-fn {#'symreg/config->log-steps (fn [_ _] 10)}
+ (fn []
+ (binding [ops/*scoring-method* :r-squared]
+ (let [{:keys [final-population iters-done] :as resp}
+ (symreg/run-find-formula run-config)
+ sorted-scores (sort (:pop-scores final-population))]
+ ;; R² scores can be negative (bad) to 1.0 (perfect)
+ ;; Best scores should be closer to 1.0
+ (is (= 10 (count (:pop final-population))))
+ (is (= 5 iters-done))
+ ;; Best score should be better (higher, closer to 1) than worst
+ (is (> (last sorted-scores) (first sorted-scores)))
+ ;; R² for good fits will still be negative
+ (is (neg? (last sorted-scores))))))))
+
+ (testing "different scoring methods produce different score ranges"
+ (reset! symreg/sim-input-args* {})
+ (with-redefs-fn {#'symreg/config->log-steps (fn [_ _] 10)}
+ (fn []
+ (let [mae-result (binding [ops/*scoring-method* :mae-max]
+ (symreg/run-find-formula run-config))
+ log-cosh-result (binding [ops/*scoring-method* :log-cosh]
+ (symreg/run-find-formula run-config))
+ r2-result (binding [ops/*scoring-method* :r-squared]
+ (symreg/run-find-formula run-config))
+ mae-best (apply max (:pop-scores (:final-population mae-result)))
+ log-cosh-best (apply max (:pop-scores (:final-population log-cosh-result)))
+ r2-best (apply max (:pop-scores (:final-population r2-result)))]
+ ;; MAE and log-cosh best scores are negative (less negative = better)
+ (is (neg? mae-best))
+ (is (neg? log-cosh-best))
+ ;; R² best score should still be negative for reasonable fits
+ (is (neg? r2-best))
+ ;; R² is bounded above by 1.0
+ (is (<= r2-best 1.0))))))))
+
+
+(deftest cross-method-scoring-comparison
+ (testing "compare solutions from different scoring methods using all scoring metrics"
+ (let [;; Simple quadratic data: y = x^2
+ xs-vec (mapv double (range 1 6))
+ ys-vec (mapv (fn [x] (+ (* x x 0.5) x 1)) xs-vec) ;; 0.5 * x^2 + x + 1
+ ys-arr (double-array ys-vec)
+
+ run-config {:input-phenos-count 15
+ :initial-muts (ops-init/initial-mutations)
+ :iters 8
+ :use-gui? false
+ :use-flamechart false
+ :random-seed 789
+ :input-xs-exprs (ops-common/doubles->exprs xs-vec)
+ :input-ys-exprs (ops-common/doubles->exprs ys-vec)}
+
+ ;; Helper to evaluate a phenotype under a specific scoring method
+ score-pheno-with-method (fn [pheno method]
+ (let [f-of-xs (ops-eval/eval-vec-pheno
+ pheno
+ {:input-xs-list (ops-common/exprs->exprs-list
+ (ops-common/doubles->exprs xs-vec))
+ :input-xs-count (count xs-vec)})]
+ (when f-of-xs
+ (#'ops/compute-score-from-actuals-and-expecteds
+ pheno f-of-xs ys-vec
+ (.leafCount ^IExpr (:expr pheno))
+ ys-arr
+ method))))
+
+ ;; Run with each scoring method and get best solution
+ _ (reset! symreg/sim-input-args* {})
+ mae-result (with-redefs-fn {#'symreg/config->log-steps (fn [_ _] 10)}
+ (fn []
+ (symreg/run-find-formula (assoc run-config :scoring-method :mae-max))))
+ mae-best (first (sort-by :score > (:pop (:final-population mae-result))))
+
+ _ (reset! symreg/sim-input-args* {})
+ log-cosh-result (with-redefs-fn {#'symreg/config->log-steps (fn [_ _] 10)}
+ (fn []
+ (symreg/run-find-formula (assoc run-config :scoring-method :log-cosh))))
+ log-cosh-best (first (sort-by :score > (:pop (:final-population log-cosh-result))))
+
+ _ (reset! symreg/sim-input-args* {})
+ r2-result (with-redefs-fn {#'symreg/config->log-steps (fn [_ _] 10)}
+ (fn []
+ (symreg/run-find-formula (assoc run-config :scoring-method :r-squared))))
+ r2-best (first (sort-by :score > (:pop (:final-population r2-result))))
+
+ ;; Score each best solution under all three methods
+ mae-best-scores {:mae-max (score-pheno-with-method mae-best :mae-max)
+ :log-cosh (score-pheno-with-method mae-best :log-cosh)
+ :r-squared (score-pheno-with-method mae-best :r-squared)}
+ log-cosh-best-scores {:mae-max (score-pheno-with-method log-cosh-best :mae-max)
+ :log-cosh (score-pheno-with-method log-cosh-best :log-cosh)
+ :r-squared (score-pheno-with-method log-cosh-best :r-squared)}
+ r2-best-scores {:mae-max (score-pheno-with-method r2-best :mae-max)
+ :log-cosh (score-pheno-with-method r2-best :log-cosh)
+ :r-squared (score-pheno-with-method r2-best :r-squared)}]
+
+ ;; Log results for inspection
+ ;(println "\n=== Cross-Method Scoring Comparison ===")
+ ;(println "MAE-trained best formula:" (str (:expr mae-best)))
+ ;(println " scored as MAE:" (:mae-max mae-best-scores)
+ ; "log-cosh:" (:log-cosh mae-best-scores)
+ ; "R²:" (:r-squared mae-best-scores))
+ ;
+ ;(println "Log-cosh-trained best formula:" (str (:expr log-cosh-best)))
+ ;(println " scored as MAE:" (:mae-max log-cosh-best-scores)
+ ; "log-cosh:" (:log-cosh log-cosh-best-scores)
+ ; "R²:" (:r-squared log-cosh-best-scores))
+ ;
+ ;(println "R²-trained best formula:" (str (:expr r2-best)))
+ ;(println " scored as MAE:" (:mae-max r2-best-scores)
+ ; "log-cosh:" (:log-cosh r2-best-scores)
+ ; "R²:" (:r-squared r2-best-scores))
+ ;(println "========================================\n")
+
+ ;; Basic assertions - all scores should be numeric and negative (closer to 0 = better)
+ (is (number? (:mae-max mae-best-scores)))
+ (is (number? (:log-cosh mae-best-scores)))
+ (is (number? (:r-squared mae-best-scores)))
+
+ ;; Each solution should score best (or very well) under its own training method
+ ;; This is a sanity check that the scoring methods are being applied correctly
+ (is (or (neg? (:mae-max mae-best-scores)) (zero? (:mae-max mae-best-scores))))
+ (is (or (neg? (:log-cosh log-cosh-best-scores)) (zero? (:log-cosh log-cosh-best-scores))))
+ (is (or (neg? (:r-squared r2-best-scores)) (zero? (:r-squared r2-best-scores))))
+
+ ;; R² scores should be bounded above by 0 (since we shifted by -1)
+ (is (<= (:r-squared mae-best-scores) 0))
+ (is (<= (:r-squared log-cosh-best-scores) 0))
+ (is (<= (:r-squared r2-best-scores) 0)))))
+
+
+(deftest simplicity-bias-in-run-find-formula
+ (let [run-config {:input-phenos-count 10
+ :initial-muts (ops-init/initial-mutations)
+ :iters 3
+ :use-gui? false
+ :use-flamechart false
+ :random-seed 999
+ :input-xs-exprs (->> (range 10)
+ (map (fn [i] (* 0.5 i)))
+ ops-common/doubles->exprs)
+ :input-ys-exprs (->> (range 10)
+ (map (fn [i] (+ (* 2 i) 3)))
+ ops-common/doubles->exprs)}]
+
+ (testing "simplicity-bias can be passed to run-find-formula"
+ (reset! symreg/sim-input-args* {})
+ (with-redefs-fn {#'symreg/config->log-steps (fn [_ _] 10)}
+ (fn []
+ ;; Should not throw with simplicity-bias option
+ (let [{:keys [final-population iters-done]}
+ (symreg/run-find-formula (assoc run-config :simplicity-bias :strong))]
+ (is (= 10 (count (:pop final-population))))
+ (is (= 3 iters-done))))))
+
+ (testing "different simplicity-bias levels produce different score distributions"
+ (reset! symreg/sim-input-args* {})
+ (with-redefs-fn {#'symreg/config->log-steps (fn [_ _] 10)}
+ (fn []
+ (let [result-none (symreg/run-find-formula (assoc run-config :simplicity-bias :none))
+ result-strong (symreg/run-find-formula (assoc run-config :simplicity-bias :strong))
+ scores-none (:pop-scores (:final-population result-none))
+ scores-strong (:pop-scores (:final-population result-strong))]
+ ;; With same random seed and inputs, :strong bias should have lower (more negative)
+ ;; scores due to the larger complexity penalty
+ ;; At minimum, both should complete without errors and have some population
+ (is (pos? (count scores-none)))
+ (is (pos? (count scores-strong)))
+ ;; Best scores (max) should be negative
+ (is (neg? (apply max scores-none)))
+ (is (neg? (apply max scores-strong)))))))))
+
+
(deftest can-run-experiment-gui:start-restart-stop
(when (not (GraphicsEnvironment/isHeadless))
(binding [ops/*print-top-n* 1]
(testing "gui can start and restart experiments; NOTE: do not run this while in headless mode, eg on CI"
+ (reset! symreg/sim-input-args* {})
+ ;; Verify initial state before launching async processes
+ (is (= (set (keys @symreg/sim-input-args*)) #{}))
(with-redefs-fn {#'symreg/config->log-steps (fn [_ _] 500)}
(fn []
(let [control-process
@@ -256,9 +594,6 @@
:input-iters 200
:input-phenos-count 500}))
- (is (= (set (keys @symreg/sim-input-args*))
- #{:input-xs-vec :input-ys-vec}))
-
(gui-chan* :next))
true)]
- (symreg/run-solver
+ (symreg/run-find-formula
{:initial-phenos (ops-init/initial-phenotypes 20)
:initial-muts (ops-init/initial-mutations)
:input-xs-exprs symreg/example-input-xs-exprs
:input-ys-exprs symreg/example-input-ys-exprs
:iters 20
- :use-gui? true})
+ :use-gui? true
+ :use-flamechart false})
(is (= (log-steps {:iters 100000
:initial-phenos (vec (repeat 10 0))}
- {:input-xs-count 10})
+ {:input-xs-count 10})
25))
(is (=
(#'symreg/config->log-steps {:iters 1000
:initial-phenos (vec (repeat 10000 0))}
- {:input-xs-count 150})
+ {:input-xs-count 150})
2))
(is (=
(#'symreg/config->log-steps {:iters 10
:initial-phenos (vec (repeat 10 0))}
- {:input-xs-count 10})
+ {:input-xs-count 10})
1))))
@@ -368,8 +711,17 @@
:initial-phenos
:input-iters
:input-phenos-count
+ :random-seed
+ :quiet-logs
+ :adaptive-mode
+ :use-eval-cache
+ :scoring-method
+ :simplicity-bias
:input-xs-count
:input-xs-list
:input-xs-vec
+ :input-ys-arr
:input-ys-vec
- :max-leafs}))))
+ :max-leafs
+ :mutations-blacklist
+ :log-steps}))))
diff --git a/test/closyr/test_utils.clj b/test/closyr/test_utils.clj
new file mode 100644
index 00000000..b63f40fe
--- /dev/null
+++ b/test/closyr/test_utils.clj
@@ -0,0 +1,12 @@
+(ns closyr.test-utils
+ "Test utilities and fixtures for quieter logging during tests"
+ (:require
+ [closyr.util.log :as log]))
+
+
+(defn quiet-logging-fixture
+ "Test fixture that sets log level to WARN during tests.
+ Use with: (use-fixtures :once quiet-logging-fixture)"
+ [f]
+ (log/set-log-level! :warn)
+ (f))
diff --git a/test/closyr/util_prng_test.clj b/test/closyr/util_prng_test.clj
index 05b767dd..f6113ca9 100644
--- a/test/closyr/util_prng_test.clj
+++ b/test/closyr/util_prng_test.clj
@@ -8,7 +8,7 @@
[seed n]
(prng/set-random-seed! seed)
(let [r1 (prng/rand-int n) r2 (prng/rand-int n)]
- (println "Seed: " seed " n: " n
+ #_(println "Seed: " seed " n: " n
;; https://github.com/trystan/random-seed/issues/3
;; odd that the first value is so similar for different seeds:
" rand-int: " r1
@@ -23,3 +23,44 @@
(testing "can sample rand ints"
(is (= (mapv #(test-rand-int-gen % 50) [1 5 10 20 50 75 100 1000 10000 100000])
[[36 5] [36 8] [36 22] [36 30] [36 29] [36 46] [36 36] [35 12] [44 40] [26 29]]))))
+
+
+(deftest test-random-uuid-deterministic
+ (testing "random-uuid is deterministic with seed"
+ (prng/set-random-seed! 12345)
+ (let [uuid1 (prng/random-uuid)]
+ (prng/set-random-seed! 12345)
+ (let [uuid2 (prng/random-uuid)]
+ (is (= uuid1 uuid2)
+ "Same seed should produce same UUID")))))
+
+
+(deftest test-random-uuid-different-seeds
+ (testing "random-uuid produces different results with different seeds"
+ (prng/set-random-seed! 111)
+ (let [uuid1 (prng/random-uuid)]
+ (prng/set-random-seed! 222)
+ (let [uuid2 (prng/random-uuid)]
+ (is (not= uuid1 uuid2)
+ "Different seeds should produce different UUIDs")))))
+
+
+(deftest test-shuffle-deterministic
+ (testing "shuffle is deterministic with seed"
+ (prng/set-random-seed! 42)
+ (let [result1 (prng/shuffle [1 2 3 4 5 6 7 8 9 10])]
+ (prng/set-random-seed! 42)
+ (let [result2 (prng/shuffle [1 2 3 4 5 6 7 8 9 10])]
+ (is (= result1 result2)
+ "Same seed should produce same shuffle order")))))
+
+
+(deftest test-rand-nth-deterministic
+ (testing "rand-nth is deterministic with seed"
+ (let [coll [:a :b :c :d :e :f :g :h :i :j]]
+ (prng/set-random-seed! 999)
+ (let [picks1 (vec (repeatedly 5 #(prng/rand-nth coll)))]
+ (prng/set-random-seed! 999)
+ (let [picks2 (vec (repeatedly 5 #(prng/rand-nth coll)))]
+ (is (= picks1 picks2)
+ "Same seed should produce same rand-nth sequence"))))))
diff --git a/test/closyr/util_spec_test.clj b/test/closyr/util_spec_test.clj
index 454ce815..c2f332e5 100644
--- a/test/closyr/util_spec_test.clj
+++ b/test/closyr/util_spec_test.clj
@@ -26,6 +26,7 @@
{:max-leafs 20,
:input-iters 10000,
:input-phenos-count 50000,
+ :random-seed -1,
:new-state :start,
:input-data-x
[0.0
@@ -188,7 +189,7 @@
(is (=
(reduce + 0 (map (fn [[k v]] (count v)) ss))
;; the number of total defns which have malli/schema metadata in entire src:
- 17))))))
+ 14))))))
#_(deftest decode-test
diff --git a/test/closyr/web_test.clj b/test/closyr/web_test.clj
new file mode 100644
index 00000000..8afad809
--- /dev/null
+++ b/test/closyr/web_test.clj
@@ -0,0 +1,345 @@
+(ns closyr.web-test
+ "Tests for the HTTP server and API endpoints."
+ (:require
+ [cheshire.core :as json]
+ [clojure.test :refer :all]
+ [closyr.test-utils :as test-utils]
+ [closyr.web.handlers.api :as api]
+ [closyr.web.middleware :as mw]
+ [closyr.web.routes :as routes]
+ [closyr.web.server :as server])
+ (:import
+ (java.io ByteArrayInputStream)))
+
+
+(use-fixtures :once test-utils/quiet-logging-fixture)
+
+
+;; ============================================================================
+;; Helper functions
+;; ============================================================================
+
+(defn- json-body
+ "Create a JSON body as an InputStream from a map."
+ [data]
+ (ByteArrayInputStream. (.getBytes (json/encode data) "UTF-8")))
+
+
+(defn- parse-json-body
+ "Parse JSON response body."
+ [response]
+ (when (:body response)
+ (json/parse-string (:body response) true)))
+
+
+(defn- make-request
+ "Helper to create a ring request map."
+ [method uri & {:keys [body headers params]}]
+ (cond-> {:request-method method
+ :uri uri}
+ body (assoc :body (json-body body))
+ headers (assoc :headers (merge {"content-type" "application/json"} headers))
+ body (assoc :headers (merge {"content-type" "application/json"} headers))
+ params (assoc :path-params params)))
+
+
+;; ============================================================================
+;; Server lifecycle tests
+;; ============================================================================
+
+(deftest test-server-not-running-initially
+ (testing "Server is not running when not started"
+ ;; Make sure server is stopped first
+ (server/stop!)
+ (is (not (server/running?)))))
+
+
+(deftest test-server-start-stop
+ (testing "Server can start and stop"
+ (try
+ (let [srv (server/start! {:port 3333})]
+ (is (some? srv))
+ (is (server/running?))
+ (server/stop!)
+ (is (not (server/running?))))
+ (finally
+ (server/stop!)))))
+
+
+(deftest test-server-restart
+ (testing "Starting server while running restarts it"
+ (try
+ (server/start! {:port 3334})
+ (is (server/running?))
+ ;; Start again on different port
+ (server/start! {:port 3335})
+ (is (server/running?))
+ (finally
+ (server/stop!)))))
+
+
+;; ============================================================================
+;; Routes and app handler tests
+;; ============================================================================
+
+(deftest test-routes-defined
+ (testing "Routes are defined"
+ (is (vector? routes/routes))
+ (is (pos? (count routes/routes)))))
+
+
+(deftest test-app-handler-exists
+ (testing "App handler is a function"
+ (is (fn? routes/app))))
+
+
+(deftest test-404-for-unknown-route
+ (testing "Unknown routes return 404"
+ (let [response (routes/app {:request-method :get
+ :uri "/unknown-route-xyz"})]
+ (is (= 404 (:status response))))))
+
+
+;; ============================================================================
+;; API: datasets endpoint tests
+;; ============================================================================
+
+(deftest test-datasets-endpoint
+ (testing "GET /api/datasets returns dataset list"
+ (let [response (api/datasets {})]
+ (is (= 200 (:status response)))
+ (is (= "application/json" (get-in response [:headers "Content-Type"])))
+ (let [body (parse-json-body response)]
+ (is (contains? body :datasets))
+ (is (vector? (:datasets body)))
+ (is (pos? (count (:datasets body))))))))
+
+
+(deftest test-datasets-have-required-fields
+ (testing "Datasets have required fields"
+ (let [response (api/datasets {})
+ body (parse-json-body response)
+ datasets (:datasets body)]
+ (doseq [ds datasets]
+ (is (contains? ds :id) (str "Dataset missing :id: " ds))
+ (is (contains? ds :name) (str "Dataset missing :name: " ds))
+ (is (contains? ds :xs) (str "Dataset missing :xs: " ds))
+ (is (contains? ds :ys) (str "Dataset missing :ys: " ds))))))
+
+
+(deftest test-datasets-include-known-datasets
+ (testing "Known datasets are included"
+ (let [response (api/datasets {})
+ body (parse-json-body response)
+ ids (set (map :id (:datasets body)))]
+ (is (contains? ids "h-line"))
+ (is (contains? ids "nguyen4"))
+ (is (contains? ids "nguyen5"))
+ (is (contains? ids "feynman-lorentz"))
+ (is (contains? ids "feynman-wave"))
+ (is (contains? ids "primes-100")))))
+
+
+;; ============================================================================
+;; API: CSV upload endpoint tests
+;; ============================================================================
+
+(deftest test-upload-csv-basic
+ (testing "CSV upload parses simple data"
+ (let [response (api/upload-csv {:body-params {:content "1,2\n3,4\n5,6"}})]
+ (is (= 200 (:status response)))
+ (let [body (parse-json-body response)]
+ (is (= [1.0 3.0 5.0] (:xs body)))
+ (is (= [2.0 4.0 6.0] (:ys body)))
+ (is (= 3 (:rowCount body)))))))
+
+
+(deftest test-upload-csv-with-headers
+ (testing "CSV upload handles headers"
+ (let [response (api/upload-csv {:body-params {:content "x,y\n1,1\n2,4\n3,9"}})]
+ (is (= 200 (:status response)))
+ (let [body (parse-json-body response)]
+ (is (= [1.0 2.0 3.0] (:xs body)))
+ (is (= [1.0 4.0 9.0] (:ys body)))
+ (is (= 3 (:rowCount body)))))))
+
+
+(deftest test-upload-csv-missing-content
+ (testing "CSV upload fails without content"
+ (let [response (api/upload-csv {:body-params {}})]
+ (is (= 400 (:status response)))
+ (let [body (parse-json-body response)]
+ (is (contains? body :error))))))
+
+
+(deftest test-upload-csv-invalid-data
+ (testing "CSV upload fails with invalid data"
+ (let [response (api/upload-csv {:body-params {:content "not,valid\ncsv,data"}})]
+ (is (= 400 (:status response)))
+ (let [body (parse-json-body response)]
+ (is (contains? body :error))))))
+
+
+;; ============================================================================
+;; API: solve endpoint tests
+;; ============================================================================
+
+(deftest test-solve-missing-params
+ (testing "Solve fails without xs/ys"
+ (let [response (api/solve {:body-params {}})]
+ (is (= 400 (:status response)))
+ (let [body (parse-json-body response)]
+ (is (contains? body :error))))))
+
+
+(deftest test-solve-missing-xs
+ (testing "Solve fails without xs"
+ (let [response (api/solve {:body-params {:ys [1 2 3]}})]
+ (is (= 400 (:status response)))
+ (let [body (parse-json-body response)]
+ (is (contains? body :error))))))
+
+
+(deftest test-solve-missing-ys
+ (testing "Solve fails without ys"
+ (let [response (api/solve {:body-params {:xs [1 2 3]}})]
+ (is (= 400 (:status response)))
+ (let [body (parse-json-body response)]
+ (is (contains? body :error))))))
+
+
+(deftest test-solve-returns-job-id
+ (testing "Solve returns job ID and events URL"
+ (let [response (api/solve {:body-params {:xs [1 2 3 4 5]
+ :ys [1 4 9 16 25]
+ :config {:iterations 1
+ :population 5}}})]
+ (is (= 202 (:status response)))
+ (let [body (parse-json-body response)]
+ (is (contains? body :jobId))
+ (is (contains? body :eventsUrl))
+ (is (string? (:jobId body)))
+ (is (.contains ^String (:eventsUrl body) (:jobId body)))))))
+
+
+;; ============================================================================
+;; API: job status endpoint tests
+;; ============================================================================
+
+(deftest test-get-job-not-found
+ (testing "GET job returns 404 for unknown job"
+ (let [response (api/get-job {:path-params {:id "nonexistent-job-id"}})]
+ (is (= 404 (:status response)))
+ (let [body (parse-json-body response)]
+ (is (contains? body :error))))))
+
+
+(deftest test-get-job-found
+ (testing "GET job returns job status"
+ ;; First create a job
+ (let [solve-response (api/solve {:body-params {:xs [1 2 3]
+ :ys [2 4 6]
+ :config {:iterations 1
+ :population 5}}})
+ solve-body (parse-json-body solve-response)
+ job-id (:jobId solve-body)
+ ;; Now get the job
+ response (api/get-job {:path-params {:id job-id}})]
+ (is (= 200 (:status response)))
+ (let [body (parse-json-body response)]
+ (is (contains? body :status))))))
+
+
+;; ============================================================================
+;; API: stop/pause/resume endpoint tests
+;; ============================================================================
+
+(deftest test-stop-job-not-found
+ (testing "Stop returns 404 for unknown job"
+ (let [response (api/stop-job {:path-params {:id "nonexistent-id"}})]
+ (is (= 404 (:status response))))))
+
+
+(deftest test-pause-job-not-found
+ (testing "Pause returns 404 for unknown job"
+ (let [response (api/pause-job {:path-params {:id "nonexistent-id"}})]
+ (is (= 404 (:status response))))))
+
+
+(deftest test-resume-job-not-found
+ (testing "Resume returns 404 for unknown job"
+ (let [response (api/resume-job {:path-params {:id "nonexistent-id"}})]
+ (is (= 404 (:status response))))))
+
+
+;; ============================================================================
+;; Middleware tests
+;; ============================================================================
+
+(deftest test-wrap-json-body-parses-json
+ (testing "JSON body middleware parses JSON"
+ (let [handler (fn [req] {:status 200 :body (:body-params req)})
+ wrapped (mw/wrap-json-body handler)
+ request {:headers {"content-type" "application/json"}
+ :body (json-body {:foo "bar" :num 42})}
+ response (wrapped request)]
+ (is (= {:foo "bar" :num 42} (:body response))))))
+
+
+(deftest test-wrap-json-body-handles-empty
+ (testing "JSON body middleware handles empty body"
+ (let [handler (fn [req] {:status 200 :body (:body-params req)})
+ wrapped (mw/wrap-json-body handler)
+ request {:headers {"content-type" "text/plain"}
+ :body nil}
+ response (wrapped request)]
+ (is (= 200 (:status response))))))
+
+
+(deftest test-wrap-json-body-handles-invalid-json
+ (testing "JSON body middleware returns 400 for invalid JSON"
+ (let [handler (fn [req] {:status 200 :body "ok"})
+ wrapped (mw/wrap-json-body handler)
+ request {:headers {"content-type" "application/json"}
+ :body (ByteArrayInputStream. (.getBytes "not valid json"))}
+ response (wrapped request)]
+ (is (= 400 (:status response))))))
+
+
+(deftest test-wrap-cors-adds-headers
+ (testing "CORS middleware adds headers"
+ (let [handler (fn [_] {:status 200 :headers {} :body "ok"})
+ wrapped (mw/wrap-cors handler)
+ response (wrapped {})]
+ (is (= "*" (get-in response [:headers "Access-Control-Allow-Origin"])))
+ (is (some? (get-in response [:headers "Access-Control-Allow-Methods"])))
+ (is (some? (get-in response [:headers "Access-Control-Allow-Headers"]))))))
+
+
+(deftest test-wrap-exceptions-catches-errors
+ (testing "Exception middleware catches errors"
+ (let [handler (fn [_] (throw (Exception. "Test error")))
+ wrapped (mw/wrap-exceptions handler)
+ response (wrapped {})]
+ (is (= 500 (:status response)))
+ (let [body (parse-json-body response)]
+ (is (contains? body :error))))))
+
+
+;; ============================================================================
+;; Integration tests (through the full app)
+;; ============================================================================
+
+(deftest test-app-datasets-endpoint
+ (testing "Full app handles /api/datasets"
+ (let [response (routes/app {:request-method :get
+ :uri "/api/datasets"})]
+ (is (= 200 (:status response))))))
+
+
+(deftest test-app-root-page
+ (testing "Full app handles root page"
+ (let [response (routes/app {:request-method :get
+ :uri "/"})]
+ ;; Should return 200 for the index page
+ (is (= 200 (:status response))))))
diff --git a/test/org/closyr/core/FindFormulaTest.java b/test/org/closyr/core/FindFormulaTest.java
new file mode 100644
index 00000000..71acee4c
--- /dev/null
+++ b/test/org/closyr/core/FindFormulaTest.java
@@ -0,0 +1,369 @@
+package org.closyr.core;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.matheclipse.core.interfaces.IExpr;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Tests for the FindFormula Java API that calls into Clojure symbolic regression.
+ */
+class FindFormulaTest {
+
+// @BeforeAll
+// static void initClojure() {
+// // Pre-initialize Clojure runtime to avoid timeout in first test
+// FindFormula.initializeClojure();
+// }
+
+ @BeforeEach
+ void setUp() {
+ }
+
+ @AfterEach
+ void tearDown() {
+ }
+
+ @Test
+ void testFindFormulaWithLinearData() {
+ // Simple linear relationship: y = 2x
+ double[] xs = {1.0, 2.0, 3.0, 4.0, 5.0};
+ double[] ys = {2.0, 4.0, 6.0, 8.0, 10.0};
+
+ FindFormula.Config config = new FindFormula.Config()
+ .iterations(10)
+ .populationSize(50);
+
+ FindFormula.Result result = FindFormula.findFormula(xs, ys, config);
+
+ assertNotNull(result);
+ assertNotNull(result.getFormulaString());
+ assertFalse(result.getFormulaString().isEmpty());
+ assertTrue(result.getIterationsDone() > 0);
+ assertNotNull(result.getAllSolutions());
+ assertFalse(result.getAllSolutions().isEmpty());
+
+ System.out.println("Linear data result: " + result);
+ }
+
+ @Test
+ void testFindFormulaWithQuadraticData() {
+ // Quadratic relationship: y = x^2
+ double[] xs = {1.0, 2.0, 3.0, 4.0, 5.0};
+ double[] ys = {1.0, 4.0, 9.0, 16.0, 25.0};
+
+ FindFormula.Config config = new FindFormula.Config()
+ .iterations(15)
+ .populationSize(100);
+
+ FindFormula.Result result = FindFormula.findFormula(xs, ys, config);
+
+ assertNotNull(result);
+ assertNotNull(result.getFormulaString());
+ assertTrue(result.getIterationsDone() > 0);
+
+ System.out.println("Quadratic data result: " + result);
+ }
+
+ @Test
+ void testFindFormulaWithDefaultConfig() {
+ double[] xs = {0.0, 1.0, 2.0, 3.0};
+ double[] ys = {1.0, 2.0, 5.0, 10.0};
+
+ FindFormula.Result result = FindFormula.findFormula(xs, ys);
+
+ assertNotNull(result);
+ assertNotNull(result.getFormulaString());
+ assertEquals(20, result.getIterationsDone()); // default iterations
+
+ System.out.println("Default config result: " + result);
+ }
+
+ @Test
+ void testResultContainsSymjaExpr() {
+ double[] xs = {1.0, 2.0, 3.0};
+ double[] ys = {3.0, 6.0, 9.0};
+
+ FindFormula.Config config = new FindFormula.Config()
+ .iterations(5)
+ .populationSize(30);
+
+ FindFormula.Result result = FindFormula.findFormula(xs, ys, config);
+
+ assertNotNull(result);
+ IExpr formulaExpr = result.getFormulaExpr();
+ assertNotNull(formulaExpr, "Formula IExpr should not be null");
+
+ // The IExpr should be usable for symbolic computation
+ String exprString = formulaExpr.toString();
+ assertNotNull(exprString);
+ assertFalse(exprString.isEmpty());
+
+ System.out.println("IExpr formula: " + exprString);
+ }
+
+ @Test
+ void testAllSolutionsSortedByScore() {
+ double[] xs = {1.0, 2.0, 3.0, 4.0};
+ double[] ys = {2.0, 4.0, 8.0, 16.0};
+
+ FindFormula.Config config = new FindFormula.Config()
+ .iterations(5)
+ .populationSize(50);
+
+ FindFormula.Result result = FindFormula.findFormula(xs, ys, config);
+
+ List solutions = result.getAllSolutions();
+ assertNotNull(solutions);
+ assertFalse(solutions.isEmpty());
+
+ // Verify solutions are sorted by score (descending - higher is better)
+ for (int i = 1; i < solutions.size(); i++) {
+ assertTrue(solutions.get(i - 1).getScore() >= solutions.get(i).getScore(),
+ "Solutions should be sorted by score descending");
+ }
+
+ // Best solution should match the result's formula
+ assertEquals(result.getFormulaString(), solutions.get(0).getFormulaString());
+ assertEquals(result.getScore(), solutions.get(0).getScore());
+ }
+
+ @Test
+ void testNullXsThrowsException() {
+ double[] ys = {1.0, 2.0, 3.0};
+
+ IllegalArgumentException exception = assertThrows(
+ IllegalArgumentException.class,
+ () -> FindFormula.findFormula(null, ys)
+ );
+ assertTrue(exception.getMessage().contains("null"));
+ }
+
+ @Test
+ void testNullYsThrowsException() {
+ double[] xs = {1.0, 2.0, 3.0};
+
+ IllegalArgumentException exception = assertThrows(
+ IllegalArgumentException.class,
+ () -> FindFormula.findFormula(xs, null)
+ );
+ assertTrue(exception.getMessage().contains("null"));
+ }
+
+ @Test
+ void testMismatchedArrayLengthsThrowsException() {
+ double[] xs = {1.0, 2.0, 3.0};
+ double[] ys = {1.0, 2.0};
+
+ IllegalArgumentException exception = assertThrows(
+ IllegalArgumentException.class,
+ () -> FindFormula.findFormula(xs, ys)
+ );
+ assertTrue(exception.getMessage().contains("same length"));
+ }
+
+ @Test
+ void testTooFewDataPointsThrowsException() {
+ double[] xs = {1.0};
+ double[] ys = {2.0};
+
+ IllegalArgumentException exception = assertThrows(
+ IllegalArgumentException.class,
+ () -> FindFormula.findFormula(xs, ys)
+ );
+ assertTrue(exception.getMessage().contains("At least 2"));
+ }
+
+ @Test
+ void testConfigBuilder() {
+ FindFormula.Config config = new FindFormula.Config()
+ .iterations(50)
+ .populationSize(200)
+ .maxLeafs(30)
+ .randomSeed(12345);
+
+ assertEquals(50, config.getIterations());
+ assertEquals(200, config.getPopulationSize());
+ assertEquals(30, config.getMaxLeafs());
+ assertEquals(12345, config.getRandomSeed());
+ }
+
+ @Test
+ void testConfigBuilderWithEvalCache() {
+ FindFormula.Config config = new FindFormula.Config()
+ .iterations(10)
+ .populationSize(50)
+ .useEvalCache(true);
+
+ assertTrue(config.isUseEvalCache());
+
+ // Verify it works with the solver
+ double[] xs = {1.0, 2.0, 3.0};
+ double[] ys = {2.0, 4.0, 6.0};
+
+ FindFormula.Result result = FindFormula.findFormula(xs, ys, config);
+ assertNotNull(result);
+ assertNotNull(result.getFormulaString());
+
+ System.out.println("Result with eval cache: " + result);
+ }
+
+ @Test
+ void testConfigBuilderEvalCacheDefaultsFalse() {
+ FindFormula.Config config = new FindFormula.Config();
+ assertFalse(config.isUseEvalCache());
+ }
+
+ @Test
+ void testConfigBuilderWithScoringMethod() {
+ FindFormula.Config config = new FindFormula.Config()
+ .iterations(10)
+ .populationSize(50)
+ .scoringMethod("log-cosh");
+
+ assertEquals("log-cosh", config.getScoringMethod());
+
+ // Verify it works with the solver
+ double[] xs = {1.0, 2.0, 3.0, 4.0, 5.0};
+ double[] ys = {1.0, 4.0, 9.0, 16.0, 25.0};
+
+ FindFormula.Result result = FindFormula.findFormula(xs, ys, config);
+ assertNotNull(result);
+ assertNotNull(result.getFormulaString());
+
+ System.out.println("Result with log-cosh scoring: " + result);
+ }
+
+ @Test
+ void testConfigBuilderScoringMethodDefaultsMaeMax() {
+ FindFormula.Config config = new FindFormula.Config();
+ assertEquals("mae-max", config.getScoringMethod());
+ }
+
+ @Test
+ void testScoringMethodRSquared() {
+ double[] xs = {1.0, 2.0, 3.0, 4.0, 5.0};
+ double[] ys = {2.0, 4.0, 6.0, 8.0, 10.0};
+
+ FindFormula.Config config = new FindFormula.Config()
+ .iterations(5)
+ .populationSize(30)
+ .scoringMethod("r-squared");
+
+ FindFormula.Result result = FindFormula.findFormula(xs, ys, config);
+ assertNotNull(result);
+ assertNotNull(result.getFormulaString());
+ // R-squared scores should be <= 0 (0 is perfect, negative is worse)
+ assertTrue(result.getScore() <= 0.0001, "R-squared score should be <= 0");
+
+ System.out.println("Result with r-squared scoring: " + result);
+ }
+
+ @Test
+ void testRandomSeedProducesDeterministicResults() {
+ double[] xs = {1.0, 2.0, 3.0, 4.0, 5.0};
+ double[] ys = {2.0, 4.0, 6.0, 8.0, 10.0};
+
+ FindFormula.Config config = new FindFormula.Config()
+ .iterations(5)
+ .populationSize(20)
+ .randomSeed(42);
+
+ FindFormula.Result result1 = FindFormula.findFormula(xs, ys, config);
+ FindFormula.Result result2 = FindFormula.findFormula(xs, ys, config);
+
+ assertEquals(result1.getFormulaString(), result2.getFormulaString(),
+ "Same seed should produce identical formulas");
+ assertEquals(result1.getScore(), result2.getScore(),
+ "Same seed should produce identical scores");
+ }
+
+ @Test
+ void testResultToString() {
+ double[] xs = {1.0, 2.0, 3.0};
+ double[] ys = {2.0, 4.0, 6.0};
+
+ FindFormula.Config config = new FindFormula.Config()
+ .iterations(3)
+ .populationSize(20);
+
+ FindFormula.Result result = FindFormula.findFormula(xs, ys, config);
+
+ String str = result.toString();
+ assertNotNull(str);
+ assertTrue(str.contains("Result{"));
+ assertTrue(str.contains("formula="));
+ assertTrue(str.contains("score="));
+ assertTrue(str.contains("iterations="));
+ }
+
+ @Test
+ void testSolutionToString() {
+ double[] xs = {1.0, 2.0, 3.0};
+ double[] ys = {1.0, 4.0, 9.0};
+
+ FindFormula.Config config = new FindFormula.Config()
+ .iterations(3)
+ .populationSize(20);
+
+ FindFormula.Result result = FindFormula.findFormula(xs, ys, config);
+
+ assertFalse(result.getAllSolutions().isEmpty());
+ FindFormula.Solution solution = result.getAllSolutions().get(0);
+
+ String str = solution.toString();
+ assertNotNull(str);
+ assertTrue(str.contains("Solution{"));
+ assertTrue(str.contains("formula="));
+ assertTrue(str.contains("score="));
+ }
+
+ @Test
+ void testWithTrigonometricData() {
+ // Data from y = sin(x)
+ int numPoints = 20;
+ double[] xs = new double[numPoints];
+ double[] ys = new double[numPoints];
+ for (int i = 0; i < numPoints; i++) {
+ xs[i] = i * Math.PI / 10.0;
+ ys[i] = Math.sin(xs[i]);
+ }
+
+ FindFormula.Config config = new FindFormula.Config()
+ .iterations(15)
+ .populationSize(100);
+
+ FindFormula.Result result = FindFormula.findFormula(xs, ys, config);
+
+ assertNotNull(result);
+ assertNotNull(result.getFormulaString());
+ // Trigonometric functions are complex, just verify we get a result
+ assertTrue(result.getAllSolutions().size() > 0);
+
+ System.out.println("Trig data result: " + result);
+ }
+
+ @Test
+ void testInitializeClojureIdempotent() {
+// // Should be safe to call multiple times
+// FindFormula.initializeClojure();
+// FindFormula.initializeClojure();
+// FindFormula.initializeClojure();
+
+ // Verify the API still works
+ double[] xs = {1.0, 2.0};
+ double[] ys = {1.0, 2.0};
+
+ FindFormula.Config config = new FindFormula.Config()
+ .iterations(2)
+ .populationSize(10);
+
+ FindFormula.Result result = FindFormula.findFormula(xs, ys, config);
+ assertNotNull(result);
+ }
+}