diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..7820700c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,80 @@ +# CLAUDE.md - Notes for Claude Code + +## Project Overview +Closyr is a symbolic regression tool that uses genetic algorithms to find mathematical formulas that fit data points. + +## Key Architecture + +### Symja Math Library +- Uses `org.matheclipse.core` (Symja) for symbolic math +- **Critical**: Parser creates different symbol objects than `F/x` or `F/Dummy "x"` +- The system uses `ops-common/sym-x` which is `(F/Dummy "x")` as the variable +- When parsing formula strings, the parser creates its own `x` symbol objects + +### Phenotype Structure +A phenotype is a map with: +- `:sym` - The ISymbol used in the expression (should be `ops-common/sym-x`) +- `:expr` - The IExpr mathematical expression +- `:util` - ExprEvaluator instance +- `:id` - UUID +- `:score` - Double score from evaluation + +### Evaluation Flow +1. `expr->fn` wraps expression: `Function({sym}, expr)` +2. `eval-phenotype-on-expr-args` evaluates the function at input values +3. **Critical**: The `:sym` in the phenotype MUST match the symbol used inside `:expr` + +### Thread Safety +- **Critical**: Symja's `ExprEvaluator` is NOT thread-safe +- Has internal mutable state (ArrayDeque stacks) that corrupts under concurrent access +- When using `pmap` for parallel scoring, ALWAYS create a fresh `ExprEvaluator` per evaluation +- Never share `ExprEvaluator` instances across threads + +## Keep Going Feature (Implemented) + +### Overview +Allows continuing evolution from a completed/stopped job's results by seeding the new population with previous formulas. + +### Implementation +1. **Formula Parsing** (`src/closyr/ops/initialize.clj`): + - `parse-formula->phenotype` - Parses formula string to phenotype + - `seeded-phenotypes` - Creates population seeded from formulas (default 80% seeded, 20% fresh) + - Uses recursive tree-walk to replace parser's `x` symbol with `ops-common/sym-x` + +2. **API Endpoint** (`src/closyr/web/handlers/api.clj`): + - `POST /api/jobs/:id/continue` - Continue from a completed/stopped job + - Accepts `xs`, `ys`, and `config` (including `freshPercent`) + +3. **Frontend** (`resources/public/js/solver-history.js`): + - "Keep Going" button in history items + - `keepGoingFromHistory(index)` function + +### Key Technical Solution +The Symja parser creates its own Symbol instances for "x" that are NOT equal to `F/x` or `(F/Dummy "x")`. +Solution: Recursive tree-walk that replaces any symbol named "x" with `ops-common/sym-x`: + +```clojure +(defn- make-tree-replacer [^ISymbol target-sym] + (ops-common/as-function + (fn tree-replace [^IExpr ie] + (cond + (and (.isSymbol ie) (= "x" (str ie))) target-sym + (instance? IAST ie) (.map ^IAST ie (make-tree-replacer target-sym)) + :else ie)))) +``` + +### Tests +- `test/closyr/seeded_evolution_test.clj` - Comprehensive tests for formula parsing and seeded evolution + +### Error Handling +Robust error handling at multiple levels prevents crashes during evolution: +1. **Formula validation** (`api.clj`): `valid-seed-formula?` filters corrupted formulas before seeding +2. **Scoring** (`ga.clj`): `with-score` catches exceptions, returns min-score +3. **Competition** (`ga.clj`): `compete` catches exceptions, keeps parents unchanged +4. **Evaluation** (`eval.clj`): Returns infinity vectors on failure instead of throwing + +## Build/Test Commands +- `lein test` - Run all tests +- `lein test :only ns/test-name` - Run specific test +- `lein run --web 3000` - Start the web server +- `lein run` - Start the java GUI diff --git a/README.md b/README.md index 05659296..bd3ec335 100644 --- a/README.md +++ b/README.md @@ -35,11 +35,11 @@ Use the application via GUI or in the terminal. There are 4 ways to run the application. See next section for details about the options you can provide when running the app. -### Run the provided realase JAR - -Requirements: Java +### Run the provided release JAR +Requirements: Java, Leiningen + $ lein uberjar $ java -jar closyr-0.1.0-SNAPSHOT-standalone.jar ...options here... @@ -63,7 +63,7 @@ Which is the same as: Requirements: Java, Leiningen (I will provide a deps file if enough interest) - (require '[closyr.symbolic-regression :as symreg]) + (require '[closyr.symbolic-regression :as symreg]) (symreg/run-app-with-gui) ### Build and run JAR @@ -72,27 +72,191 @@ Requirements: Java, Leiningen $ lein uberjar - $ java -jar target/uberjar/closyr-0.1.0-standalone.jar + $ java -jar target/uberjar/closyr-0.1.0-SNAPSHOT-standalone.jar You can also provide the same command-line options to `java` command, like: - $ java -jar target/uberjar/closyr-0.1.0-standalone.jar -t -p 25 -i 5 -x 0,1,2,3,4,5,6 -y 1,2,30,4,5,6,10 + $ java -jar target/uberjar/closyr-0.1.0-SNAPSHOT-standalone.jar -t -p 25 -i 5 -x 0,1,2,3,4,5,6 -y 1,2,30,4,5,6,10 + +### Run as Web Application + +You can run closyr as a web application with a browser-based UI that includes real-time progress updates via Server-Sent Events (SSE). + +Requirements: Java, Leiningen + +Start the web server on the default port (3000): + + $ lein run --web + +Or specify a custom port: + + $ lein run --web 8080 + +Then open your browser to http://localhost:3000 (or your custom port). + +The web interface provides: +- **Input data entry**: Enter X and Y values as comma-separated numbers +- **CSV file upload**: Upload a CSV file with x,y data points +- **Built-in datasets**: Choose from preset datasets (quadratic, cubic, sine, etc.) +- **Configuration**: Set iterations, population size, max expression size, and random seed +- **Real-time progress**: Watch the solver evolve formulas with live updates +- **Results display**: View the best formula found along with alternative solutions + +#### Web API + +The web application also exposes a JSON API for programmatic access: + +```bash +# Start a solver job +curl -X POST http://localhost:3000/api/solve \ + -H "Content-Type: application/json" \ + -d '{"xs": [1,2,3,4,5], "ys": [1,4,9,16,25], "config": {"iterations": 20, "population": 50}}' + +# Response: {"jobId": "uuid-here", "eventsUrl": "/api/jobs/uuid-here/events"} + +# With adaptive mode and quiet logs +curl -X POST http://localhost:3000/api/solve \ + -H "Content-Type: application/json" \ + -d '{"xs": [1,2,3,4,5], "ys": [1,4,9,16,25], "config": {"iterations": 100, "population": 200, "adaptiveMode": true, "quietLogs": true}}' + +# Check job status +curl http://localhost:3000/api/jobs/{jobId} + +# Stream progress updates (SSE) +curl http://localhost:3000/api/jobs/{jobId}/events + +# List available datasets +curl http://localhost:3000/api/datasets + +# Parse CSV content +curl -X POST http://localhost:3000/api/upload-csv \ + -H "Content-Type: application/json" \ + -d '{"content": "x,y\n1,1\n2,4\n3,9"}' +``` ## Options | Short, Long Option | Required? | Example | Default | Description | |-------------------------|-----------------|---------|---------|-------------------------------------------------------------------------------------------------------------------------------| -| `-t`,`--headless` | no | `-t` | `false` | run without GUI, in terminal only | -| `-c`,`--use-flamechart` | no | `-c` | `false` | run with flamecharts, run then visit http://localhost:54321/flames.svg | -| `-p`,`--population` | no | `100` | `20` | size of population which will evolve; the number of functions we create and modify | -| `-i`,`--iterations` | no | `50` | `10` | number of iterations to run for | -| `-l`,`--max-leafs` | no | `40` | `40` | max number of AST tree leafs in candidate functions | -| `-x`,`--xs` | no, unless `ys` | `1,3,4` | random | the xs for the points in the dataset to fit against; if provided, must also provide `ys` and be the same count | -| `-y`,`--ys` | no, unless `xs` | `2,4,8` | random | the ys for the points in the dataset to fit against; if provided, must also provide `xs` and be the same count | -| `-f`,`--infile` | no | `f.csv` | | A CSV file. Contains either 2 columns without titles in first row, or has columns `x` and `y` to be used as objective dataset | +| `--web` | no | `--web 3000` | `3000` | start HTTP web server instead of GUI on specified port | +| `-t`,`--headless` | no | `-t` | `false` | run without GUI, in terminal only | +| `-c`,`--use-flamechart` | no | `-c` | `false` | run with flamecharts, run then visit http://localhost:54321/flames.svg | +| `-p`,`--population` | no | `100` | `20` | size of population which will evolve; the number of functions we create and modify | +| `-i`,`--iterations` | no | `50` | `10` | number of iterations to run for | +| `-l`,`--max-leafs` | no | `40` | `40` | max number of AST tree leafs in candidate functions | +| `-x`,`--xs` | no, unless `ys` | `1,3,4` | random | the xs for the points in the dataset to fit against; if provided, must also provide `ys` and be the same count | +| `-y`,`--ys` | no, unless `xs` | `2,4,8` | random | the ys for the points in the dataset to fit against; if provided, must also provide `xs` and be the same count | +| `-f`,`--infile` | no | `f.csv` | | A CSV file. Contains either 2 columns without titles in first row, or has columns `x` and `y` to be used as objective dataset | +| `-s`,`--seed` | no | `42` | | Random seed for reproducible results. **Warning:** Enables deterministic mode which disables CPU parallelism | +| `-w`,`--mutations-whitelist` | no | `+Sin,-Sin` | | Comma-separated list of mutation labels to use (only these mutations will be applied) | +| `-b`,`--mutations-blacklist` | no | `Derivative` | | Comma-separated list of mutation labels to exclude | +| `-a`,`--adaptive` | no | `-a` | `false` | Enable adaptive mutation rates that adjust based on population diversity and stagnation | +| `-q`,`--quiet` | no | `-q` | `false` | Suppress detailed iteration logs (quiet mode) | +| `--cache` | no | `--cache` | `false` | Enable evaluation cache to avoid redundant score calculations for identical expressions | +| `--scoring` | no | `--scoring r-squared` | `mae-max` | Scoring method: `mae-max` (default), `log-cosh`, or `r-squared` | + +### Reproducible Results with Random Seed + +You can use the `-s` or `--seed` option to get reproducible results: + + $ lein run -t -p 20 -i 5 -x 1,2,3,4,5 -y 2,4,6,8,10 -s 42 + +Running the same command with the same seed will produce identical results. + +**Warning:** When a random seed is set, the solver enters **deterministic mode** which disables CPU parallelism. This ensures reproducibility but may result in slower execution times for large populations. + +### Filtering Mutations with Whitelist/Blacklist + +You can control which mutations are used during evolution using whitelist and blacklist options: + +```bash +# Only use trigonometric mutations +$ lein run -t -p 100 -i 50 -x 1,2,3,4,5 -y 2,4,6,8,10 -w "+Sin,-Sin,+Cos,-Cos,*Sin,*Cos" + +# Exclude derivative and logarithm mutations +$ lein run -t -p 100 -i 50 -x 1,2,3,4,5 -y 2,4,6,8,10 -b "Derivative,+Log,-Log" + +# Combine both: start with trig mutations, exclude +Sin +$ lein run -t -p 100 -i 50 -x 1,2,3,4,5 -y 2,4,6,8,10 -w "+Sin,-Sin,+Cos,-Cos" -b "+Sin" +``` + +Common mutation labels include: `Derivative`, `+Sin`, `-Sin`, `+Cos`, `-Cos`, `*Sin`, `*Cos`, `+Log`, `-Log`, `+Exp`, `-Exp`, `+x`, `-x`, `*x`, `/x`, `+1/2`, `-1/2`, `*2`, `/2`, and many more. + +### Adaptive Mutation Rates + +The `-a` or `--adaptive` flag enables adaptive mutation rates. When enabled, the solver dynamically adjusts: + +- **Mutation vs Crossover ratio**: Increases mutation rate when population diversity is low or when progress stagnates +- **Mutation count per individual**: Applies more mutations when stuck in local optima + +```bash +# Run with adaptive mutation rates +$ lein run -t -p 200 -i 100 -x 1,2,3,4,5 -y 1,4,9,16,25 -a + +# Combine with quiet mode for cleaner output +$ lein run -t -p 200 -i 100 -x 1,2,3,4,5 -y 1,4,9,16,25 -a -q +``` + +The adaptive system tracks: +- **Population diversity**: Score spread between best and median individuals +- **Stagnation**: Iterations without fitness improvement +- **History**: Recent best scores to detect convergence trends + +When the population converges (low diversity) or stagnates (no improvement for several iterations), the system automatically increases exploration by raising mutation rates and applying more mutations per individual. + +### Evaluation Cache + +The `--cache` flag enables caching of evaluation results by expression string. When the same expression appears multiple times (which is common during evolution), cached scores are returned instead of re-computing them. + +```bash +# Run with evaluation cache enabled +$ lein run -t -p 200 -i 100 -x 1,2,3,4,5 -y 1,4,9,16,25 --cache + +# Combine with adaptive mode +$ lein run -t -p 200 -i 100 -x 1,2,3,4,5 -y 1,4,9,16,25 -a --cache +``` + +The cache is most beneficial when: +- Using larger populations where duplicate expressions are more likely +- Running many iterations where expressions may recur +- Using deterministic mode (with `--seed`) where the same mutations may produce the same results + +The cache is cleared at the start of each run. In the GUI and webapp interfaces, the evaluation cache option is available in the Advanced Settings. + +### Scoring Methods + +The `--scoring` option allows you to choose different fitness scoring methods. All methods return 0 for a perfect fit, with more negative values indicating worse fits. + +```bash +# Use R² scoring (coefficient of determination) +$ lein run -t -p 100 -i 50 -x 1,2,3,4,5 -y 1,4,9,16,25 --scoring r-squared + +# Use log-cosh scoring (robust to outliers) +$ lein run -t -p 100 -i 50 -x 1,2,3,4,5 -y 1,4,9,16,25 --scoring log-cosh + +# Use default MAE scoring +$ lein run -t -p 100 -i 50 -x 1,2,3,4,5 -y 1,4,9,16,25 --scoring mae-max +``` + +Available scoring methods: + +| Method | Description | +|--------|-------------| +| `mae-max` | Default. Negative of (2×MAE + max residual). Penalizes both average error and worst-case outliers. | +| `log-cosh` | Log-cosh loss. Smooth like MSE for small errors, robust like MAE for large errors. No hyperparameter tuning needed. | +| `r-squared` | R² coefficient of determination minus 1. Perfect fit = 0, predictions at mean = -1, worse predictions < -1. | + +**Choosing a scoring method:** +- Use `mae-max` (default) for general-purpose symbolic regression +- Use `log-cosh` when your data may have outliers but you still want smooth gradients for small errors +- Use `r-squared` when you want to measure how well the expression explains variance in the data + +## Q + A + +- `How do I run multiple jobs concurrently?` : Open a new tab with the webapp, or run another GUI, or open another terminal. The app does not inherently support concurrent jobs, from UI perspective. ## Example Screenshots -On successful application start, you can start an run a search. You might see something like this when done: +On successful application start, you can start and run a search. You might see something like this when done: ![gui_done_2024-02-12_08-44.png](screenshots%2Fgui_done_2024-02-12_08-44.png) @@ -106,7 +270,49 @@ An example of using the GUI: Coverage looks like this if you run `lein cloverage`: -![test_coverage_2024-02-16_16-39.png](screenshots%2Ftest_coverage_2024-02-16_16-39.png) +![test_coverage_2025-12-29_17-44.png](screenshots/test_coverage_2025-12-29_17-44.png) + +## Benchmarks + +Inspired by https://github.com/omron-sinicx/srsd-benchmark + +The project includes benchmark tests using standard symbolic regression test functions from the literature: + +### Running Benchmarks + +```bash +# Run all benchmark tests +$ lein test :only closyr.benchmark-functions-test + +# Run a specific benchmark +$ lein test :only closyr.benchmark-functions-test/nguyen-4-benchmark +``` + +### Benchmark Functions + +| Benchmark | Formula | Domain | Description | +|-----------|---------|--------|-------------| +| **Nguyen-4** | x⁶ + x⁵ + x⁴ + x³ + x² + x | [-1, 1] | High-degree polynomial | +| **Nguyen-5** | sin(x²)·cos(x) - 1 | [-1, 1] | Trigonometric composition | +| **Feynman Lorentz** | 1/√(1 - v²/c²) | [0, 0.95] | Relativistic Lorentz factor | +| **Feynman Wave** | sin(kx - ωt) | [0, 4π] | Traveling wave equation | + +These benchmarks are from the [Nguyen benchmark suite](https://gpbenchmarks.org/) and [AI Feynman dataset](https://space.mit.edu/home/tegmark/aifeynman.html), commonly used in symbolic regression research. + +### Example Results + +Results from running benchmarks with 200 population, 100 iterations, seed=42: + +| Benchmark | Best Score | Time | Best Formula Found | +|------------------|------------|-------|-------------------| +| Nguyen-4 | -0.91 | 4.91s | `-1/100+Sin(x)+x*(x+1/50*x*Csc(x)*(-1/100+E^(2*(-1/10+E)^x)-11/10*Sin(121.0*x)))` | +| Nguyen-5 | -0.29 | 2.00s | `-9601/10000` | +| Feynman Lorentz | -0.72 | 2.67s | `1/2+x-Cos(x)+Cos(1/2-x)*(-x^2+0.9*Log(-1/100+3.05997*x^4+Cos(x)))` | +| Feynman Wave | -0.14 | 2.37s | `-Cos(3/5+x)` | + +*Score is negative sum of residuals (closer to 0 is better). Times measured on AMD Ryzen 9.* + +**Note:** Benchmarks use deterministic mode (random seed) for reproducibility, which disables parallelism. Production runs without a seed will be faster. ## How It Works @@ -116,18 +322,307 @@ Coverage looks like this if you run `lein cloverage`: - Mutations act on the function's AST and modify branches, leafs, or the whole tree. Operations like `+0.1`, `*x`, `/Sin(x)` are applied to functions. - Crossovers combine two functions' ASTs at a random point, and combine using various operators like `+`, and `*`. +### Java API + +You can use closyr as a library from Java or any JVM language. The Java API provides a clean interface layered on top of the Clojure implementation: + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ Your Java Application │ +└─────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ Java API (org.closyr.api) │ +│ ┌─────────────────┐ ┌──────────────────┐ ┌────────────────────────┐ │ +│ │ FormulaFinder │ │ FormulaConfig │ │ IFormulaResult │ │ +│ │ .find(xs, ys) │ │ .iterations() │ │ .getBestSolution() │ │ +│ │ .create() │ │ .populationSize │ │ .getAllSolutions() │ │ +│ │ │ │ .randomSeed() │ │ .getIterationsRun() │ │ +│ └─────────────────┘ └──────────────────┘ └────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ Clojure Implementation Layer │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ closyr.api.finder ──▶ closyr.symbolic-regression │ │ +│ │ │ │ │ │ +│ │ ▼ ▼ │ │ +│ │ closyr.api.types closyr.ga (genetic algorithm) │ │ +│ │ │ │ │ +│ │ ▼ │ │ +│ │ closyr.ops (mutations/crossover) │ │ +│ │ │ │ │ +│ │ ▼ │ │ +│ │ Symja (symbolic math engine) │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +#### Java Usage Example + +```java +import org.closyr.api.*; + +public class Example { + public static void main(String[] args) { + // Input data points + double[] xs = {1.0, 2.0, 3.0, 4.0, 5.0}; + double[] ys = {2.0, 4.0, 6.0, 8.0, 10.0}; + + // Simple usage with defaults + IFormulaResult result = FormulaFinder.find(xs, ys); + + // Or with custom configuration + IFormulaConfig config = FormulaConfigBuilder.builder() + .iterations(50) + .populationSize(100) + .maxLeafs(40) + .randomSeed(42) // Optional: for reproducible results + .build(); + + result = FormulaFinder.find(xs, ys, config); + + // Get the best formula found + IFormulaSolution best = result.getBestSolution(); + System.out.println("Formula: " + best.getFormula()); + System.out.println("Score: " + best.getScore()); + } +} +``` + +#### Reproducible Results in Java + +Use `randomSeed()` in the config builder for reproducible results: + +```java +IFormulaConfig config = FormulaConfigBuilder.builder() + .iterations(20) + .populationSize(50) + .randomSeed(12345) // Same seed = same results + .build(); +``` + +**Warning:** Setting a random seed enables **deterministic mode** which disables CPU parallelism. This ensures reproducibility but may result in slower execution. + +#### Adaptive Mutation Rates in Java + +Enable adaptive mutation rates to dynamically adjust exploration based on population diversity: + +```java +IFormulaConfig config = FormulaConfigBuilder.builder() + .iterations(100) + .populationSize(200) + .adaptiveMode(true) // Enable adaptive mutation rates + .quietLogs(true) // Optional: suppress detailed iteration logs + .build(); + +IFormulaResult result = FormulaFinder.find(xs, ys, config); +``` + +When adaptive mode is enabled, the solver automatically: +- Increases mutation rates when population diversity is low +- Applies more mutations per individual when progress stagnates +- Reduces mutation intensity when making steady progress + +#### Filtering Mutations in Java + +Use `mutationsWhitelist()` and `mutationsBlacklist()` to control which mutations are used: + +```java +// Only use trigonometric mutations +IFormulaConfig config = FormulaConfigBuilder.builder() + .iterations(50) + .populationSize(100) + .mutationsWhitelist("+Sin", "-Sin", "+Cos", "-Cos", "*Sin", "*Cos") + .build(); + +// Exclude specific mutations +IFormulaConfig config = FormulaConfigBuilder.builder() + .iterations(50) + .populationSize(100) + .mutationsBlacklist("Derivative", "+Log", "-Log") + .build(); + +// Or using FindFormula.Config directly +FindFormula.Config config = new FindFormula.Config() + .iterations(50) + .populationSize(100) + .mutationsWhitelist("+Sin", "-Sin", "+Cos", "-Cos") + .mutationsBlacklist("+Sin"); // Further exclude from whitelist +``` + +#### Scoring Methods in Java + +Choose different fitness scoring methods using `scoringMethod()`: + +```java +// Use R² scoring (coefficient of determination) +FindFormula.Config config = new FindFormula.Config() + .iterations(50) + .populationSize(100) + .scoringMethod("r-squared"); + +// Use log-cosh scoring (robust to outliers) +FindFormula.Config config = new FindFormula.Config() + .iterations(50) + .populationSize(100) + .scoringMethod("log-cosh"); + +// Use default MAE scoring (explicitly) +FindFormula.Config config = new FindFormula.Config() + .iterations(50) + .populationSize(100) + .scoringMethod("mae-max"); + +// Or with FormulaConfigBuilder +IFormulaConfig config = FormulaConfigBuilder.builder() + .iterations(50) + .populationSize(100) + .scoringMethod("r-squared") + .build(); +``` + +Available scoring methods: +- `mae-max` (default): Negative of (2×MAE + max residual). Penalizes both average error and worst-case outliers. +- `log-cosh`: Log-cosh loss. Smooth like MSE for small errors, robust like MAE for large errors. +- `r-squared`: R² coefficient of determination minus 1. Perfect fit = 0, worse predictions are more negative. + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────────────────────────┐ +│ ENTRY POINTS │ +├─────────────────────┬─────────────────────┬─────────────────────────────────────┤ +│ closyr.core │ closyr.api.finder │ closyr.symbolic-regression │ +│ (CLI -main) │ (Java API) │ (Main Orchestrator) │ +└─────────┬───────────┴──────────┬──────────┴──────────────┬──────────────────────┘ + │ │ │ + └──────────────────────┼─────────────────────────┘ + ▼ +┌─────────────────────────────────────────────────────────────────────────────────┐ +│ GENETIC ALGORITHM │ +│ ┌─────────────────────────────────────────────────────────────────────────┐ │ +│ │ closyr.ga │ │ +│ │ (Selection, Crossover, Mutation Loop) │ │ +│ └─────────────────────────────────────────────────────────────────────────┘ │ +└───────────────────────────────────┬─────────────────────────────────────────────┘ + ▼ +┌─────────────────────────────────────────────────────────────────────────────────┐ +│ EXPRESSION OPERATIONS │ +│ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ │ +│ │ closyr.ops │───▶│ ops.modify │ │ ops.eval │ │ +│ │ (Facade) │ │ (Mutations & │ │ (Evaluate │ │ +│ │ │───▶│ Crossover) │ │ Expressions) │ │ +│ └───────┬───────┘ └───────────────┘ └───────────────┘ │ +│ │ ┌───────────────┐ ┌───────────────┐ │ +│ └───────────▶│ ops.initialize│ │ ops.common │ │ +│ │ (Population │ │ (Shared Utils)│ │ +│ │ Seeding) │ │ │ │ +│ └───────────────┘ └───────────────┘ │ +└───────────────────────────────────┬─────────────────────────────────────────────┘ + ▼ +┌─────────────────────────────────────────────────────────────────────────────────┐ +│ SYMJA (External) │ +│ Symbolic Math Engine - AST Manipulation & Evaluation │ +│ github.com/axkr/symja_android_library │ +└─────────────────────────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────────────────────────┐ +│ UI LAYER │ +│ ┌─────────────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ closyr.ui.gui │ │ closyr.ui.plot │ │ closyr.ui.icons │ │ +│ │ (Swing/Seesaw GUI) │───▶│ (XChart) │ │ │ │ +│ │ - Sketchpad │ │ - Best Fn │ │ │ │ +│ │ - Controls │ │ - Scores │ │ │ │ +│ │ - Function Display │ │ │ │ │ │ +│ └─────────────────────────┘ └─────────────────┘ └─────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────────────────────┐ │ +│ │ WEB LAYER (--web mode) │ │ +│ │ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ │ │ +│ │ │ web.server │ │ web.routes │ │ web.sse │ │ │ +│ │ │ (Jetty) │ │ (Reitit) │ │ (SSE Stream) │ │ │ +│ │ └───────────────┘ └───────────────┘ └───────────────┘ │ │ +│ │ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ │ │ +│ │ │ handlers.api │ │ handlers.pages│ │ web.middleware│ │ │ +│ │ │ (JSON API) │ │ (Selmer HTML) │ │ (CORS, JSON) │ │ │ +│ │ └───────────────┘ └───────────────┘ └───────────────┘ │ │ +│ └─────────────────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────────────────────────┐ +│ DATA LAYER │ +│ ┌─────────────────────────┐ ┌───────────────────────────────────────────┐ │ +│ │ closyr.dataset.inputs │ │ Built-in Datasets │ │ +│ │ (Benchmark Functions) │ │ - prime-10000 (nth prime) │ │ +│ │ - Nguyen-4, Nguyen-5 │ │ - prime-counting (π(x)) │ │ +│ │ - Feynman Lorentz/Wave │ │ │ │ +│ │ - Trig, Log, Gaussian │ │ │ │ +│ └─────────────────────────┘ └───────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────────────────────────┐ +│ UTILITIES │ +│ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ │ +│ │ util.log │ │ util.csv │ │ util.prng │ │ util.spec │ │ +│ │ (Logging) │ │ (CSV Import) │ │ (Seeded RNG) │ │ (Malli Specs) │ │ +│ └───────────────┘ └───────────────┘ └───────────────┘ └───────────────┘ │ +└─────────────────────────────────────────────────────────────────────────────────┘ + +Data Flow: + 1. User provides (x,y) data via GUI sketchpad, CSV file, or CLI args + 2. GA creates initial population of random Symja expressions + 3. Each iteration: evaluate fitness (sum of residuals), select, mutate, crossover + 4. Best expressions shown in real-time on charts + 5. Final best-fit function returned as Symja-compatible expression +``` + ## Roadmap - [x] CLI options accept a CSV file (GUI already supports this) -- [ ] More tests -- [ ] Use something like ProGuard to shrink the JAR for releases - - https://www.guardsquare.com/manual/configuration/examples - - https://stackoverflow.com/questions/12281365/obfuscating-clojure-uberjars-with-proguard - - https://github.com/eiffelqiu/obfuscate-clojure-project-demo/tree/master -- [ ] A different frontend. The current Java Swing frontend does the job, but it's not easy to maintain and it's hard to make look better. +- [x] Web frontend with HTTP API and SSE for real-time updates +- [ ] Use something like ProGuard to shrink the JAR for releases + - https://www.guardsquare.com/manual/configuration/examples + - https://stackoverflow.com/questions/12281365/obfuscating-clojure-uberjars-with-proguard + - https://github.com/eiffelqiu/obfuscate-clojure-project-demo/tree/master - [ ] Can this be a follow-up to this issue, asking for a symbolic regression tool on the JVM? https://github.com/axkr/symja_android_library/issues/850 - [ ] When I created this and my other symbolic regression tools, I didn't know about the formal field of symbolic regression. I've since found some great libraries that I should review and apply the lessons to this project: https://github.com/MilesCranmer/PySR +### Other TODOs + +#### CLI + +- [ ] CLI accepts JSON file option for input data / config, and prints results in JSON + +#### Webapp + +- [ ][Tiny] Clojure GA: maybe mark simplify fn code in common.clj as deprecated since we don't use it, we rely on .eval for IExpr to simplify +- [x] Clojure GA / Webapp UI: display scores without length-deduction. that way we could compare jobs which have different simplicity biases because the scores purely reflect the residuals. we should probably compare jobs using this score without length deduction applied. that would also mean to show this score while jobs are running, when they are stopped, and when they are done. it would still be useful to show the length deduction if there is any for jobs are they are running, when they are done, stopped, and in history. +- [x] Bug: Webapp / Job Results when job is stopped: it seems to show prior job's scoring method, scores and other results if another job before it completed. it needs to show the data for the done/stopped job. +- [x] Clojure GA / Webapp UI: configurable length-deduction. right now we use a small length deduction for phenotypes just to break ties for same score -> smaller pheno has slightly better score. I want to be able to change to a couple different other levels, where the length deduction is more significant. 2 other levels (maybe a 3-tick slider?) which has options like "Tiebreaker" (default), "Slight", "More". but with maybe better language. add this option to relevant job UI elements in the webapp. +- [x] Webapp / Job History: stopped jobs should show up in the correct place in the job tree +- [x] Webapp / Job History: removing a single job item should move children to level of tree where deleted item was, preserving the children trees structure. +- [ ] [Should we do this?] Webapp: Job History: option to move subtree to base level. +- [x] Webapp: Running jobs: Move the pause / stop buttons under Configuration section to Results section. Keep the Configuration Find Formula button so we can kick off concurrent jobs from that section. +- [x] Webapp: Change the score progression chart (in Results in progress, complete and in Job History) to sparklines near each scoring method value for each scoring method. +- [x] Update webapp frontend "beforeunload" handler to stop all running jobs. +- [x] Webapp / Eval cache bug: concurrent jobs using different scoring methods, when both using eval cache, share a cache and that is bad because different scoring methods result in different scores. +- [x] Webapp / Jobs/Results/Job History: we need an editable text label initially populated using the text label we use in the job tabs. to be used by user to label the job with their own comments. Editable at any time. +- [x] Webapp job config: Mutations should have some toggle-able groups in addition to selecting individuals. I want to group trig functions, exp+log, poly, analytic (derivatives, etc.), and any other obvious groups. first show the mutation groups but have a way for user to still select individual mutations. +- [x] Webapp / Jobs, Results, Job history: show scores for all scoring methods for job results / job history but making sure to make it clear which is the job's scoring method used to find the formula. it's useful to see the functions' different scores regardless of which scoring method was used in find-formula. +- [ ] Webapp / Job History: a better visual indicator for job score and where it ranks among job tree +- [x] Webapp / Jobs: a way to run jobs concurrently, and each running job gets its own Results area, which becomes tabbed to switch between running jobs. This requires a new option/button like "Keep Going With Job Config" but more succinct (for each job history item), which reuses the job config/data from the job you clicked on, instead of config/data from the UI user inputs. Each tab should have shortcuts to pause/stop the job. The existing pause / stop buttons should apply to the currently selected job in the tabs. +- [x] Webapp / Job History: a way to delete item + children of item +- [x] Webapp / Job History: add job to new tree (no parents) when input data is different and did "Keep Going". A tree should be 1 dataset. Keep Going options should reuse x/y data from the job you clicked on. +- [x] Webapp / Job History: add a way to collapse any part of the history tree, with an expand / collapse icon is to the left of the job item. +- [x] Webapp / Job History: fix bug where if I change scoring method while a job is running, it will show in job history with the newly changed value instead of the one from the running job. +- [x] Webapp / Job Results: copy to clipboard hover btn scrolls with the formulas instead of staying in the corner, when formulas a long and have h-scroll. + +#### Java GUI + +- [ ] do we still want to keep a java gui around? all new functionality is going into webapp right now. ## Credits diff --git a/doc/depstree.txt b/doc/depstree.txt index 0c23dc99..8ffc50f2 100644 --- a/doc/depstree.txt +++ b/doc/depstree.txt @@ -1,5 +1,5 @@ - [ch.qos.logback/logback-classic "1.5.0"] - [ch.qos.logback/logback-core "1.5.0"] + [ch.qos.logback/logback-classic "1.5.23"] + [ch.qos.logback/logback-core "1.5.23"] [clojure-complete "0.2.5" :exclusions [[org.clojure/clojure]]] [flames "0.5.0"] [http-kit "2.6.0"] @@ -26,30 +26,30 @@ [org.swinglabs/swingx "1.6.1" :scope "runtime"] [com.jhlabs/filters "2.0.235" :scope "runtime"] [org.swinglabs/swing-worker "1.1" :scope "runtime"] - [metosin/malli "0.14.0"] + [metosin/malli "0.20.0"] [borkdude/dynaload "0.3.5"] - [borkdude/edamame "1.3.23"] - [fipp "0.6.26"] + [borkdude/edamame "1.4.32"] + [fipp "0.6.29"] [org.clojure/core.rrb-vector "0.1.2"] [mvxcvi/arrangement "2.1.0"] [org.clojure/test.check "1.1.1"] [nrepl "0.8.3" :exclusions [[org.clojure/clojure]]] - [org.apache.logging.log4j/log4j-api "2.22.1"] - [org.apache.logging.log4j/log4j-core "2.22.1"] - [org.clojure/clojure "1.11.1"] - [org.clojure/core.specs.alpha "0.2.62"] - [org.clojure/spec.alpha "0.3.218"] - [org.clojure/core.async "1.6.681"] - [org.clojure/tools.analyzer.jvm "1.2.3"] - [org.clojure/core.memoize "1.0.253"] - [org.clojure/core.cache "1.0.225"] - [org.clojure/data.priority-map "1.1.0"] - [org.clojure/tools.analyzer "1.1.1"] - [org.clojure/tools.reader "1.3.6"] + [org.apache.logging.log4j/log4j-api "2.25.3"] + [org.apache.logging.log4j/log4j-core "2.25.3"] + [org.clojure/clojure "1.12.4"] + [org.clojure/core.specs.alpha "0.4.74"] + [org.clojure/spec.alpha "0.5.238"] + [org.clojure/core.async "1.8.741"] + [org.clojure/tools.analyzer.jvm "1.3.2"] + [org.clojure/core.memoize "1.1.266"] + [org.clojure/core.cache "1.1.234"] + [org.clojure/data.priority-map "1.2.0"] + [org.clojure/tools.analyzer "1.2.0"] + [org.clojure/tools.reader "1.5.0"] [org.ow2.asm/asm "9.2"] [org.clojure/data.csv "1.1.0"] - [org.clojure/tools.cli "1.1.230"] - [org.knowm.xchart/xchart "3.8.7"] + [org.clojure/tools.cli "1.2.245"] + [org.knowm.xchart/xchart "3.8.8"] [com.madgag/animated-gif-lib "1.4"] [de.erichseifert.vectorgraphics2d/VectorGraphics2D "0.13"] [de.rototor.pdfbox/graphics2d "3.0.1"] @@ -65,57 +65,60 @@ [org.junit.jupiter/junit-jupiter-engine "5.10.1" :scope "runtime"] [org.junit.platform/junit-platform-engine "1.10.1" :scope "runtime"] [org.junit.jupiter/junit-jupiter-params "5.10.1"] - [org.matheclipse/matheclipse-core "3.1.0-20240219.223701-64" :exclusions [[org.slf4j/slf4j-api] [com.fasterxml.jackson.core/jackson-core]]] - [com.esotericsoftware/kryo "5.6.0"] + [org.matheclipse/matheclipse-core "3.1.0-SNAPSHOT" :exclusions [[org.slf4j/slf4j-api] [com.fasterxml.jackson.core/jackson-core]]] + [com.esotericsoftware/kryo "5.6.2"] [com.esotericsoftware/minlog "1.3.1"] [com.esotericsoftware/reflectasm "1.11.9"] - [org.objenesis/objenesis "3.3"] + [org.objenesis/objenesis "3.4"] [com.github.freva/ascii-table "1.8.0"] [com.univocity/univocity-parsers "2.9.1"] + [commons-io "2.18.0"] [de.labathome/AdaptiveQuadrature "1.0.2"] - [it.unimi.dsi/fastutil-core "8.5.13"] - [org.apache.commons/commons-csv "1.10.0"] - [org.apache.commons/commons-lang3 "3.14.0"] - [org.apfloat/apfloat "1.13.0"] - [org.choco-solver/choco-solver "4.10.15-20240118.152009-24"] + [it.unimi.dsi/fastutil-core "8.5.15"] + [org.apache.commons/commons-csv "1.13.0"] + [commons-codec "1.17.2"] + [org.apache.commons/commons-lang3 "3.17.0"] + [org.apfloat/apfloat "1.14.1-SNAPSHOT"] + [org.choco-solver/choco-solver "4.10.18" :exclusions [[org.knowm.xchart/xchart]]] [dk.brics.automaton/automaton "1.11-8"] [net.sf.trove4j/trove4j "3.0.3"] [org.ehcache/sizeof "0.4.3"] [org.codehaus.janino/janino "3.1.12"] [org.codehaus.janino/commons-compiler "3.1.12"] [org.gavaghan/geodesy "1.1.3"] - [org.hipparchus/hipparchus-clustering "3.1-20240212.230522-23"] - [org.hipparchus/hipparchus-core "3.1-20240212.230522-23"] - [org.hipparchus/hipparchus-stat "3.1-20240212.230522-23"] - [org.hipparchus/hipparchus-fft "3.1-20240212.230522-23"] - [org.hipparchus/hipparchus-fitting "3.1-20240212.230522-23"] - [org.hipparchus/hipparchus-ode "3.1-20240212.230522-23"] - [org.hipparchus/hipparchus-optim "3.1-20240212.230522-23"] - [org.jgrapht/jgrapht-io "1.5.3-20240118.050300-19"] + [org.hipparchus/hipparchus-clustering "4.0-20250316.160502-27"] + [org.hipparchus/hipparchus-stat "4.0-20250316.160502-27"] + [org.hipparchus/hipparchus-fft "4.0-20250316.160502-27"] + [org.hipparchus/hipparchus-fitting "4.0-20250316.160502-27"] + [org.hipparchus/hipparchus-ode "4.0-20250316.160502-27"] + [org.hipparchus/hipparchus-optim "4.0-20250316.160502-27"] + [org.jgrapht/jgrapht-io "1.5.3-SNAPSHOT"] [org.antlr/antlr4-runtime "4.13.1"] [org.apache.commons/commons-text "1.11.0"] - [org.jgrapht/jgrapht-core "1.5.3-20240118.050300-19"] + [org.jgrapht/jgrapht-core "1.5.3-SNAPSHOT"] [org.jheaps/jheaps "0.14"] - [org.logicng/logicng-j11 "2.4.3"] - [org.matheclipse/matheclipse-external "3.1.0-20240219.223701-64"] - [com.fasterxml.jackson.core/jackson-databind "2.16.1"] - [com.fasterxml.jackson.core/jackson-annotations "2.16.1"] - [com.google.guava/guava "33.0.0-jre"] + [org.logicng/logicng "2.6.0"] + [org.matheclipse/matheclipse-external "3.1.0-SNAPSHOT"] + [com.fasterxml.jackson.core/jackson-databind "2.18.3"] + [com.fasterxml.jackson.core/jackson-annotations "2.18.3"] + [com.google.guava/guava "33.4.0-jre"] [com.google.code.findbugs/jsr305 "3.0.2"] - [com.google.errorprone/error_prone_annotations "2.23.0"] + [com.google.errorprone/error_prone_annotations "2.36.0"] [com.google.guava/failureaccess "1.0.2"] [com.google.guava/listenablefuture "9999.0-empty-to-avoid-conflict-with-guava"] - [com.google.j2objc/j2objc-annotations "2.8"] - [org.checkerframework/checker-qual "3.41.0"] - [io.pebbletemplates/pebble "3.2.2"] + [com.google.j2objc/j2objc-annotations "3.0.0"] + [org.checkerframework/checker-qual "3.43.0"] + [io.pebbletemplates/pebble "3.2.3"] [org.unbescape/unbescape "1.1.6.RELEASE"] - [org.matheclipse/matheclipse-parser "3.1.0-20240219.223701-64"] + [org.hipparchus/hipparchus-core "4.0-20250316.160502-27"] + [org.matheclipse/matheclipse-parser "3.1.0-SNAPSHOT"] [org.organicdesign/Paguro "3.10.3"] [org.jetbrains/annotations "23.0.0"] - [org.matheclipse/matheclipse-gpl "3.1.0-20240219.223701-64" :exclusions [[org.slf4j/slf4j-api] [com.fasterxml.jackson.core/jackson-core]]] - [org.apache.logging.log4j/log4j-1.2-api "2.22.1"] - [org.slf4j/jcl-over-slf4j "2.0.12"] - [org.slf4j/slf4j-api "2.0.12"] + [us.hebi.matlab.mat/mfl-core "0.5.15"] + [org.matheclipse/matheclipse-gpl "3.1.0-SNAPSHOT" :exclusions [[org.slf4j/slf4j-api] [com.fasterxml.jackson.core/jackson-core]]] + [org.apache.logging.log4j/log4j-1.2-api "2.24.3"] + [org.slf4j/jcl-over-slf4j "2.0.17"] + [org.slf4j/slf4j-api "2.0.17"] [seesaw "1.5.0"] [com.fifesoft/rsyntaxtextarea "2.5.6"] [com.jgoodies/forms "1.2.1"] diff --git a/doc/problem_table_easy.pdf b/doc/problem_table_easy.pdf new file mode 100644 index 00000000..8f2d241c Binary files /dev/null and b/doc/problem_table_easy.pdf differ diff --git a/doc/problem_table_hard.pdf b/doc/problem_table_hard.pdf new file mode 100644 index 00000000..7ccdc3fd Binary files /dev/null and b/doc/problem_table_hard.pdf differ diff --git a/doc/problem_table_medium.pdf b/doc/problem_table_medium.pdf new file mode 100644 index 00000000..c247e612 Binary files /dev/null and b/doc/problem_table_medium.pdf differ diff --git a/nomis-ns-graph.png b/nomis-ns-graph.png index 90347ced..012fa6e3 100644 Binary files a/nomis-ns-graph.png and b/nomis-ns-graph.png differ diff --git a/perf-profile/README.md b/perf-profile/README.md new file mode 100644 index 00000000..35ebef28 --- /dev/null +++ b/perf-profile/README.md @@ -0,0 +1,7 @@ +# How to run + +In project root: + +```sh +lein run -m clojure.main ./perf-profile/profile_test.clj # or other profile class +``` diff --git a/perf-profile/benchmark.clj b/perf-profile/benchmark.clj new file mode 100644 index 00000000..bbb44ec7 --- /dev/null +++ b/perf-profile/benchmark.clj @@ -0,0 +1,104 @@ +(ns benchmark + (:require [closyr.ops.common :as ops-common] + [closyr.ops.eval :as ops-eval] + [closyr.ops.initialize :as ops-init] + [closyr.ops :as ops] + [closyr.ga :as ga] + [closyr.util.prng :as prng])) + +(defn benchmark-evolution + "Benchmark evolution with optional deterministic mode. + When random-seed is provided, runs in deterministic (single-threaded) mode." + [pop-size n-iters max-leafs xs-max & {:keys [random-seed]}] + (binding [ga/*deterministic-mode* (some? random-seed)] + (when random-seed + (prng/set-random-seed! random-seed)) + (let [phenos (ops-init/initial-phenotypes pop-size) + muts (ops-init/initial-mutations) + xs (vec (range 0.1 xs-max 0.2)) + ys (mapv #(+ (* % %) (Math/sin %)) xs) + input-xs-list (ops-common/exprs->exprs-list (ops-common/doubles->exprs xs)) + run-args {:input-xs-list input-xs-list + :input-xs-count (count xs) + :input-xs-vec xs + :input-ys-vec ys + :input-ys-arr (double-array ys)} + run-config {:max-leafs max-leafs} + score-fn (partial ops/score-fn run-args run-config) + mut-fn (partial ops/mutation-fn run-config muts) + cross-fn (partial ops/crossover-fn run-config muts) + ga-config (ga/initialize phenos score-fn mut-fn cross-fn)] + + ;; Warmup + (dotimes [_ 3] (ga/evolve ga-config)) + + ;; Timed run + (let [start (System/nanoTime) + result (loop [config ga-config, i 0] + (if (< i n-iters) + (recur (ga/evolve config) (inc i)) + config)) + elapsed-ms (/ (- (System/nanoTime) start) 1e6)] + {:pop-size pop-size + :iters n-iters + :elapsed-ms elapsed-ms + :ms-per-iter (/ elapsed-ms n-iters) + :phenos-per-sec (/ (* pop-size n-iters 1000) elapsed-ms) + :deterministic (some? random-seed)})))) + +(println "\n=== Evolution Benchmark ===\n") + +(def results* (atom [])) + +(doseq [pop-size [10 100 500 1000 10000]] + (swap! results* concat ["\n"]) + (doseq [xs-max [5.0 20.0]] + (doseq [max-leafs [40 120]] + (let [result (benchmark-evolution pop-size 10 max-leafs xs-max)] + (swap! results* concat [(format "Pop %4d, XsMax %4f, MaxLeafs %4d: %6.1f ms/iter, %8.0f phenos/sec\n" + pop-size + xs-max + max-leafs + (:ms-per-iter result) + (:phenos-per-sec result))]))))) + +(println "\n=== Parallel Benchmark Done ===") +(println "\n=== Results (Parallel Mode) ===") +(println (apply str @results*)) + +;; Deterministic benchmark +(println "\n=== Deterministic Benchmark (single-threaded) ===\n") + +(def deterministic-results* (atom [])) + +(doseq [pop-size [10 100 500 1000]] + (swap! deterministic-results* concat ["\n"]) + (doseq [xs-max [5.0 20.0]] + (doseq [max-leafs [40 120]] + (let [result (benchmark-evolution pop-size 10 max-leafs xs-max :random-seed 12345)] + (swap! deterministic-results* concat [(format "Pop %4d, XsMax %4.1f, MaxLeafs %4d: %6.1f ms/iter, %8.0f phenos/sec (deterministic)\n" + pop-size + xs-max + max-leafs + (:ms-per-iter result) + (:phenos-per-sec result))]))))) + +(println "\n=== Results (Deterministic Mode) ===") +(println (apply str @deterministic-results*)) + +;; Comparison summary +(println "\n=== Parallel vs Deterministic Comparison ===\n") + +(doseq [pop-size [100 1000 5000]] + (let [parallel-result (benchmark-evolution pop-size 10 40 5.0) + deterministic-result (benchmark-evolution pop-size 10 40 5.0 :random-seed 42) + speedup (/ (:phenos-per-sec parallel-result) (:phenos-per-sec deterministic-result))] + (println (format "Pop %4d: Parallel %8.0f phenos/sec, Deterministic %8.0f phenos/sec, Speedup: %.2fx" + pop-size + (:phenos-per-sec parallel-result) + (:phenos-per-sec deterministic-result) + speedup)))) + +(println "\n=== All Benchmarks Complete ===") + +(System/exit 0) diff --git a/perf-profile/eval_comparison.clj b/perf-profile/eval_comparison.clj new file mode 100644 index 00000000..4fd824af --- /dev/null +++ b/perf-profile/eval_comparison.clj @@ -0,0 +1,49 @@ +(ns eval-comparison + (:require [closyr.ops.common :as ops-common] + [closyr.ops.eval :as ops-eval]) + (:import [org.matheclipse.core.expression F] + [org.matheclipse.core.interfaces IExpr IAST ISymbol])) + +(defn compare-eval [] + (let [x ops-common/sym-x + expr (F/Plus (F/Sin x) (F/Times x x)) + util (ops-common/new-util) + pheno {:sym x :util util :expr expr :id (java.util.UUID/randomUUID)} + + xs (vec (range 0.1 10.0 0.1)) + n (count xs) + xs-list (ops-common/exprs->exprs-list (ops-common/doubles->exprs xs)) + run-args {:input-xs-list xs-list :input-xs-count n} + + ;; Pre-compute for isolated tests + fn-expr (ops-common/expr->fn pheno) + ast (F/ast xs-list fn-expr)] + + (println "\n=== Evaluation Time Breakdown ===\n") + (println "Points:" n) + + ;; Warmup + (dotimes [_ 50] (ops-eval/eval-vec-pheno pheno run-args)) + + (println "\n1. Current optimized eval-vec-pheno (1000x):") + (time (dotimes [_ 1000] (ops-eval/eval-vec-pheno pheno run-args))) + + (println "\n2. Just the .eval call (1000x):") + (time (dotimes [_ 1000] (.eval util ast))) + + (println "\n3. Just result extraction from pre-computed result (1000x):") + (let [^IAST result (.eval util ast)] + (time (dotimes [_ 1000] + (loop [i (int 0), acc (transient [])] + (if (< i n) + (let [^IExpr e (.getArg result (inc i) F/Infinity) + v (if (.isReal e) + (ops-common/expr->double e) + Double/POSITIVE_INFINITY)] + (recur (unchecked-inc-int i) (conj! acc v))) + (persistent! acc)))))) + + (println "\nConclusion: .eval is ~95% of eval time, extraction is ~5%") + (println "\n=== Done ==="))) + +(compare-eval) diff --git a/perf-profile/eval_detailed.clj b/perf-profile/eval_detailed.clj new file mode 100644 index 00000000..92be2519 --- /dev/null +++ b/perf-profile/eval_detailed.clj @@ -0,0 +1,76 @@ +(ns eval-detailed + (:require [closyr.ops.common :as ops-common] + [closyr.ops.eval :as ops-eval]) + (:import [org.matheclipse.core.expression F] + [org.matheclipse.core.interfaces IExpr IAST ISymbol] + [org.matheclipse.core.eval ExprEvaluator])) + +(defn detailed-timing [] + (let [x ops-common/sym-x + expr (F/Plus (F/Sin x) (F/Times x x)) + util (ops-common/new-util) + pheno {:sym x :util util :expr expr :id (java.util.UUID/randomUUID)} + + xs (vec (range 0.1 10.0 0.1)) + n (count xs) + xs-exprs (ops-common/doubles->exprs xs) + xs-list (ops-common/exprs->exprs-list xs-exprs) + run-args {:input-xs-list xs-list :input-xs-count n}] + + (println "\n=== Detailed Timing Breakdown ===\n") + + ;; Warmup + (dotimes [_ 50] (ops-eval/eval-vec-pheno pheno run-args)) + + (println "1. Full eval-vec-pheno (1000x):") + (time (dotimes [_ 1000] (ops-eval/eval-vec-pheno pheno run-args))) + + (println "\n2. expr->fn creation (10000x):") + (time (dotimes [_ 10000] (ops-common/expr->fn pheno))) + + (println "\n3. F/ast creation with pre-computed fn-expr (10000x):") + (let [fn-expr (ops-common/expr->fn pheno)] + (time (dotimes [_ 10000] (F/ast xs-list fn-expr)))) + + (println "\n4. .eval on pre-computed ast (1000x):") + (let [fn-expr (ops-common/expr->fn pheno) + ast (F/ast xs-list fn-expr)] + (time (dotimes [_ 1000] (.eval ^ExprEvaluator util ast)))) + + (println "\n5. Full eval-phenotype-on-expr-args (1000x):") + (time (dotimes [_ 1000] (ops-eval/eval-phenotype-on-expr-args pheno xs-list))) + + (println "\n6a. OLD parseable-eval-result? with string (10000x):") + (let [fn-expr (ops-common/expr->fn pheno) + ast (F/ast xs-list fn-expr) + result (.eval ^ExprEvaluator util ast)] + (time (dotimes [_ 10000] + (not (or (nil? result) (= "Indeterminate" (str result))))))) + + (println "\n6b. NEW parseable-eval-result? with identical? (10000x):") + (let [fn-expr (ops-common/expr->fn pheno) + ast (F/ast xs-list fn-expr) + result (.eval ^ExprEvaluator util ast)] + (time (dotimes [_ 10000] + (and (some? result) (not (identical? result F/Indeterminate)))))) + + (println "\n7. Result extraction only (1000x):") + (let [fn-expr (ops-common/expr->fn pheno) + ast (F/ast xs-list fn-expr) + ^IAST result (.eval ^ExprEvaluator util ast)] + (time (dotimes [_ 1000] + (loop [i (int 0), acc (transient [])] + (if (< i n) + (let [^IExpr e (.getArg result (inc i) F/Infinity) + v (if (.isReal e) + (ops-common/expr->double e) + Double/POSITIVE_INFINITY)] + (recur (unchecked-inc-int i) (conj! acc v))) + (persistent! acc)))))) + + (println "\n=== Reconstructed total ===") + (println "Expected: expr->fn + F/ast + .eval + extraction") + (println "(Values from above, scaled to 1000x)") + (println "\n=== Done ==="))) + +(detailed-timing) diff --git a/perf-profile/explore_symja.clj b/perf-profile/explore_symja.clj new file mode 100644 index 00000000..836f331d --- /dev/null +++ b/perf-profile/explore_symja.clj @@ -0,0 +1,69 @@ +(ns explore-symja + (:require [closyr.ops.common :as ops-common]) + (:import [org.matheclipse.core.expression F] + [org.matheclipse.core.eval ExprEvaluator EvalEngine] + [org.matheclipse.core.interfaces IExpr IAST ISymbol])) + +;; Explore Symja's numeric evaluation options +(defn test-evaluation-methods [] + (let [x (F/Dummy "x") + expr (F/Plus (F/Sin x) (F/Times x x)) ; sin(x) + x^2 + util (ops-common/new-util) + test-vals [0.0 0.5 1.0 1.5 2.0]] + + (println "\n=== Exploring Symja Evaluation Methods ===\n") + + ;; Method 1: Current approach - evaluate function on list + (println "1. Current F/ast approach:") + (let [xs-exprs (mapv #(F/num %) test-vals) + xs-arr (into-array IExpr xs-exprs) + xs-list (into-array IExpr [(F/List xs-arr)]) + fn-expr (F/Function (F/List (into-array ISymbol [x])) expr) + ast (F/ast xs-list fn-expr) + result (.eval util ast)] + (println " Result:" (str result))) + + ;; Method 2: Try evalDouble on substituted expression + (println "\n2. Direct evalDouble per point:") + (doseq [v test-vals] + (let [subst-expr (.subs expr x (F/num v)) + evaled (.eval util subst-expr)] + (when (.isReal evaled) + (println " x=" v "-> y=" (.evalDouble evaled))))) + + ;; Method 3: Check if expression can compile to DoubleUnaryOperator + (println "\n3. Check IExpr numeric methods:") + (let [simple-expr (F/Times (F/num 2.0) x)] + (println " Has isNumericFunction:" (.isNumericFunction simple-expr true)) + (println " Expr class:" (class expr))) + + ;; Method 4: Benchmark extraction from list result + (println "\n4. Benchmark: mapv vs areduce for result extraction:") + (let [xs-exprs (mapv #(F/num %) (range 0.1 10.0 0.1)) + xs-arr (into-array IExpr xs-exprs) + xs-list (into-array IExpr [(F/List xs-arr)]) + fn-expr (F/Function (F/List (into-array ISymbol [x])) expr) + ast (F/ast xs-list fn-expr) + ^IAST result (.eval util ast) + n (dec (.size result))] + + (println " Result size:" n) + + ;; mapv approach (current) + (print " mapv extraction (1000x): ") + (time (dotimes [_ 1000] + (mapv (fn [i] + (let [^IExpr e (.getArg result (inc i) F/Infinity)] + (if (.isReal e) (.evalDouble e) Double/POSITIVE_INFINITY))) + (range n)))) + + ;; Direct array approach + (print " array extraction (1000x): ") + (time (dotimes [_ 1000] + (let [arr (double-array n)] + (dotimes [i n] + (let [^IExpr e (.getArg result (inc i) F/Infinity)] + (aset arr i (if (.isReal e) (.evalDouble e) Double/POSITIVE_INFINITY)))) + arr)))))) + +(test-evaluation-methods) diff --git a/perf-profile/explore_symja2.clj b/perf-profile/explore_symja2.clj new file mode 100644 index 00000000..f6257e74 --- /dev/null +++ b/perf-profile/explore_symja2.clj @@ -0,0 +1,72 @@ +(ns explore-symja2 + (:require [closyr.ops.common :as ops-common] + [closyr.ops.eval :as ops-eval] + [closyr.ops.initialize :as ops-init]) + (:import [org.matheclipse.core.expression F] + [org.matheclipse.core.interfaces IExpr IAST ISymbol])) + +(defn benchmark-eval-paths [] + (let [x ops-common/sym-x + ;; Create a more complex expression like GA would produce + expr (F/Plus (F/Sin x) (F/Times x x) (F/Cos (F/Times (F/num 2.0) x))) + pheno (ops-common/->phenotype x expr nil) + util (ops-common/new-util) + + ;; Create input data + xs (vec (range 0.1 10.0 0.1)) ; 100 points + n (count xs) + xs-exprs (ops-common/doubles->exprs xs) + xs-list (ops-common/exprs->exprs-list xs-exprs) + run-args {:input-xs-list xs-list :input-xs-count n}] + + (println "\n=== Full Evaluation Path Benchmark ===\n") + (println "Expression:" (str expr)) + (println "Points:" n) + + ;; Warmup + (dotimes [_ 10] (ops-eval/eval-vec-pheno pheno run-args)) + + ;; Current implementation + (println "\n1. Current eval-vec-pheno (1000x):") + (time (dotimes [_ 1000] + (ops-eval/eval-vec-pheno pheno run-args))) + + ;; Break down the components + (println "\n2. Component breakdown (1000x each):") + + ;; Just the F/ast + eval part + (let [fn-expr (ops-common/expr->fn pheno)] + (print " a. F/ast creation: ") + (time (dotimes [_ 1000] + (F/ast xs-list fn-expr))) + + (let [ast (F/ast xs-list fn-expr)] + (print " b. .eval on ast: ") + (time (dotimes [_ 1000] + (.eval util ast))))) + + ;; Just result extraction + (let [fn-expr (ops-common/expr->fn pheno) + ast (F/ast xs-list fn-expr) + ^IAST result (.eval util ast)] + (print " c. Result extraction (mapv): ") + (time (dotimes [_ 1000] + (mapv (fn [i] + (let [^IExpr e (.getArg result (inc i) F/Infinity)] + (if (.isReal e) + (ops-common/expr->double e) + Double/POSITIVE_INFINITY))) + (range n))))) + + ;; Try direct double evaluation without going through IExpr + (println "\n3. Alternative: Direct substitution approach (100x):") + (print " Per-point subs+eval: ") + (time (dotimes [_ 100] + (mapv (fn [xv] + (let [subst (.subs expr x (F/num xv))] + (.evalDouble (.eval util subst)))) + xs))) + + (println "\n=== Done ==="))) + +(benchmark-eval-paths) diff --git a/perf-profile/explore_symja3.clj b/perf-profile/explore_symja3.clj new file mode 100644 index 00000000..b8e4eb0f --- /dev/null +++ b/perf-profile/explore_symja3.clj @@ -0,0 +1,62 @@ +(ns explore-symja3 + (:require [closyr.ops.common :as ops-common] + [closyr.ops.eval :as ops-eval]) + (:import [org.matheclipse.core.expression F] + [org.matheclipse.core.interfaces IExpr IAST ISymbol])) + +(defn benchmark-overhead [] + (let [x ops-common/sym-x + expr (F/Plus (F/Sin x) (F/Times x x) (F/Cos (F/Times (F/num 2.0) x))) + util (ops-common/new-util) + + xs (vec (range 0.1 10.0 0.1)) + n (count xs) + xs-list (ops-common/exprs->exprs-list (ops-common/doubles->exprs xs)) + + ;; Pre-create everything + pheno {:sym x :util util :expr expr :id (java.util.UUID/randomUUID)} + fn-expr (ops-common/expr->fn pheno)] + + (println "\n=== Finding the Overhead ===\n") + (println "Expression:" (str expr)) + + ;; Warmup + (dotimes [_ 100] + (let [ast (F/ast xs-list fn-expr)] + (.eval util ast))) + + ;; Minimal path with pre-computed fn-expr + (println "\n1. Minimal: pre-computed fn-expr + eval (1000x):") + (time (dotimes [_ 1000] + (let [ast (F/ast xs-list fn-expr) + ^IAST result (.eval util ast)] + (mapv (fn [i] + (let [^IExpr e (.getArg result (inc i) F/Infinity)] + (if (.isReal e) (.evalDouble e) Double/POSITIVE_INFINITY))) + (range n))))) + + ;; With expr->fn called each time (like current code) + (println "\n2. With expr->fn each time (1000x):") + (time (dotimes [_ 1000] + (let [fn-expr-new (ops-common/expr->fn pheno) + ast (F/ast xs-list fn-expr-new) + ^IAST result (.eval util ast)] + (mapv (fn [i] + (let [^IExpr e (.getArg result (inc i) F/Infinity)] + (if (.isReal e) (.evalDouble e) Double/POSITIVE_INFINITY))) + (range n))))) + + ;; Current eval-vec-pheno + (println "\n3. Current eval-vec-pheno (1000x):") + (let [run-args {:input-xs-list xs-list :input-xs-count n}] + (time (dotimes [_ 1000] + (ops-eval/eval-vec-pheno pheno run-args)))) + + ;; Check expr->fn overhead + (println "\n4. Just expr->fn creation (10000x):") + (time (dotimes [_ 10000] + (ops-common/expr->fn pheno))) + + (println "\n=== Done ==="))) + +(benchmark-overhead) diff --git a/perf-profile/explore_symja4.clj b/perf-profile/explore_symja4.clj new file mode 100644 index 00000000..8da431d7 --- /dev/null +++ b/perf-profile/explore_symja4.clj @@ -0,0 +1,90 @@ +(ns explore-symja4 + (:require [closyr.ops.common :as ops-common] + [closyr.ops.eval :as ops-eval]) + (:import [org.matheclipse.core.expression F] + [org.matheclipse.core.interfaces IExpr IAST ISymbol] + [org.matheclipse.core.eval ExprEvaluator])) + +(defn benchmark-optimized [] + (let [x ops-common/sym-x + expr (F/Plus (F/Sin x) (F/Times x x) (F/Cos (F/Times (F/num 2.0) x))) + util (ops-common/new-util) + + xs (vec (range 0.1 10.0 0.1)) + n (count xs) + xs-list (ops-common/exprs->exprs-list (ops-common/doubles->exprs xs)) + run-args {:input-xs-list xs-list :input-xs-count n} + + pheno {:sym x :util util :expr expr :id (java.util.UUID/randomUUID)}] + + (println "\n=== Optimized Evaluation Benchmark ===\n") + + ;; Warmup + (dotimes [_ 100] (ops-eval/eval-vec-pheno pheno run-args)) + + ;; Current + (println "1. Current eval-vec-pheno (1000x):") + (time (dotimes [_ 1000] + (ops-eval/eval-vec-pheno pheno run-args))) + + ;; Optimized version - inline everything, no intermediate functions + (println "\n2. Optimized inline version (1000x):") + (time + (dotimes [_ 1000] + (let [^IAST fn-expr (F/Function + (F/List (into-array ISymbol [x])) + expr) + ^IAST ast (F/ast xs-list fn-expr) + ^IExpr eval-result (.eval ^ExprEvaluator util ast)] + (when-not (or (nil? eval-result) + (= "Indeterminate" (str eval-result))) + (let [result-size (dec (.size eval-result))] + (if (= n result-size) + ;; Normal case - one result per input + (loop [i 0, acc (transient [])] + (if (< i n) + (let [^IExpr e (.getArg eval-result (inc i) F/Infinity) + v (if (.isReal e) + (.doubleValue (.toNumber e)) + Double/POSITIVE_INFINITY)] + (recur (inc i) (conj! acc v))) + (persistent! acc))) + ;; Constant result case + (let [^IExpr arg0 (.getArg eval-result 0 F/Infinity) + v (.doubleValue (.toNumber (if (.isReal expr) expr arg0)))] + (vec (repeat n v))))))))) + + ;; With transient but using mapv structure + (println "\n3. Transient with indexed loop (1000x):") + (time + (dotimes [_ 1000] + (let [^IAST fn-expr (F/Function (F/List (into-array ISymbol [x])) expr) + ^IAST ast (F/ast xs-list fn-expr) + ^IAST result (.eval ^ExprEvaluator util ast)] + (loop [i 0, acc (transient [])] + (if (< i n) + (let [^IExpr e (.getArg result (inc i) F/Infinity) + v (if (.isReal e) + (.doubleValue (.toNumber e)) + Double/POSITIVE_INFINITY)] + (recur (inc i) (conj! acc v))) + (persistent! acc)))))) + + ;; Using primitive double array + (println "\n4. Primitive double array (1000x):") + (time + (dotimes [_ 1000] + (let [^IAST fn-expr (F/Function (F/List (into-array ISymbol [x])) expr) + ^IAST ast (F/ast xs-list fn-expr) + ^IAST result (.eval ^ExprEvaluator util ast) + ^doubles arr (double-array n)] + (dotimes [i n] + (let [^IExpr e (.getArg result (inc i) F/Infinity)] + (aset arr i (if (.isReal e) + (.doubleValue (.toNumber e)) + Double/POSITIVE_INFINITY)))) + arr))) + + (println "\n=== Done ==="))) + +(benchmark-optimized) diff --git a/perf-profile/profile_eval.clj b/perf-profile/profile_eval.clj new file mode 100644 index 00000000..3cf56d3f --- /dev/null +++ b/perf-profile/profile_eval.clj @@ -0,0 +1,42 @@ +(ns profile-eval + (:require [closyr.ops.common :as ops-common]) + (:import [org.matheclipse.core.expression F] + [org.matheclipse.core.eval ExprEvaluator])) + +(defn profile-eval [] + (let [x ops-common/sym-x + util (ops-common/get-thread-local-util) + simple-expr (F/Plus x (F/C1D2)) + complex-expr (F/D (F/Sin (F/Times x x)) x)] ;; derivative is expensive + + (println "\n=== Eval in ->phenotype ===\n") + + (println "1. Creating expression WITHOUT .eval (1000x):") + (time (dotimes [_ 1000] + {:sym x :util nil :expr simple-expr})) + + (println "\n2. Creating phenotype WITH .eval on simple expr (1000x):") + (time (dotimes [_ 1000] + (.eval util simple-expr))) + + (println "\n3. Creating derivative expr (1000x):") + (time (dotimes [_ 1000] + (F/D (F/Sin x) x))) + + (println "\n4. Eval derivative result (1000x):") + (let [deriv (F/D (F/Sin x) x)] + (time (dotimes [_ 1000] + (.eval util deriv)))) + + (println "\n5. Complex derivative F/D(Sin(x^2), x) (100x):") + (time (dotimes [_ 100] + (F/D (F/Sin (F/Times x x)) x))) + + (println "\n6. Eval complex derivative (100x):") + (let [deriv (F/D (F/Sin (F/Times x x)) x)] + (time (dotimes [_ 100] + (.eval util deriv)))) + + (println "\n=== Done ===\n"))) + +(profile-eval) diff --git a/perf-profile/profile_mutations.clj b/perf-profile/profile_mutations.clj new file mode 100644 index 00000000..ce8996c1 --- /dev/null +++ b/perf-profile/profile_mutations.clj @@ -0,0 +1,51 @@ +(ns profile-test + (:require [closyr.ops.common :as ops-common] + [closyr.ops.eval :as ops-eval] + [closyr.ops.initialize :as ops-init] + [closyr.ops :as ops] + [closyr.ga :as ga])) + +(defn profile-components [] + (let [phenos (ops-init/initial-phenotypes 100) + muts (ops-init/initial-mutations) + xs (vec (range 0.1 10.0 0.2)) + ys (mapv #(+ (* % %) (Math/sin %)) xs) + input-xs-list (ops-common/exprs->exprs-list (ops-common/doubles->exprs xs)) + run-args {:input-xs-list input-xs-list + :input-xs-count (count xs) + :input-xs-vec xs + :input-ys-vec ys} + run-config {:max-leafs 40}] + + (println "\n=== Profiling Components ===\n") + + ;; Profile phenotype creation + (println "1. Creating 1000 phenotypes:") + (let [x ops-common/sym-x] + (time (dotimes [_ 1000] + (ops-common/->phenotype x (org.matheclipse.core.expression.F/Sin x) nil)))) + + ;; Profile evaluation + (println "\n2. Evaluating 100 phenotypes on" (count xs) "points:") + (time (doseq [p phenos] + (ops-eval/eval-vec-pheno p run-args))) + + ;; Profile scoring + (println "\n3. Scoring 100 phenotypes:") + (time (doseq [p phenos] + (ops/score-fn run-args run-config p))) + + ;; Profile mutation + (println "\n4. Applying 100 mutations:") + (time (doseq [p phenos] + (ops/mutation-fn run-config muts p p))) + + ;; Profile full evolution step + (println "\n5. Full evolution step (100 pop):") + (let [score-fn (partial ops/score-fn run-args run-config) + mut-fn (partial ops/mutation-fn run-config muts) + cross-fn (partial ops/crossover-fn run-config muts) + ga-config (ga/initialize phenos score-fn mut-fn cross-fn)] + (time (ga/evolve ga-config))) + + (println "\n=== Done ===\n"))) diff --git a/perf-profile/profile_test.clj b/perf-profile/profile_test.clj new file mode 100644 index 00000000..ea7b6c4f --- /dev/null +++ b/perf-profile/profile_test.clj @@ -0,0 +1,49 @@ +(ns profile-mutations + (:require [closyr.ops.common :as ops-common] + [closyr.ops.modify :as ops-modify] + [closyr.ops.initialize :as ops-init]) + (:import [org.matheclipse.core.expression F])) + +(defn profile-mutation-internals [] + (let [x ops-common/sym-x + pheno (ops-common/->phenotype x (F/Plus (F/Sin x) (F/Times x x)) nil) + muts (ops-init/initial-mutations)] + + (println "\n=== Mutation Internals ===\n") + + ;; Profile single modification types + (println "1. Single modify-fn mutation (100x):") + (let [mod-fn (first (filter #(= :modify-fn (:op %)) muts))] + (time (dotimes [_ 100] + (ops-modify/modify mod-fn pheno)))) + + (println "\n2. Single modify-leafs mutation (100x):") + (let [mod-leafs (first (filter #(= :modify-leafs (:op %)) muts))] + (time (dotimes [_ 100] + (ops-modify/modify mod-leafs pheno)))) + + (println "\n3. Single modify-branches mutation (100x):") + (let [mod-branch (first (filter #(= :modify-branches (:op %)) muts))] + (time (dotimes [_ 100] + (ops-modify/modify mod-branch pheno)))) + + (println "\n4. apply-modifications with 1 mod (100x):") + (time (dotimes [_ 100] + (ops-modify/apply-modifications 40 1 muts pheno pheno))) + + (println "\n5. apply-modifications with 5 mods (100x):") + (time (dotimes [_ 100] + (ops-modify/apply-modifications 40 5 muts pheno pheno))) + + (println "\n6. Just F/Sin creation (1000x):") + (time (dotimes [_ 1000] + (F/Sin x))) + + (println "\n7. Just ->phenotype from expr (1000x):") + (let [expr (F/Plus (F/Sin x) x)] + (time (dotimes [_ 1000] + (ops-common/->phenotype x expr nil)))) + + (println "\n=== Done ===\n"))) + +(profile-mutation-internals) diff --git a/perf-profile/score_benchmark.clj b/perf-profile/score_benchmark.clj new file mode 100644 index 00000000..66150968 --- /dev/null +++ b/perf-profile/score_benchmark.clj @@ -0,0 +1,90 @@ +(ns score-benchmark + (:require [closyr.ops.common :as ops-common] + [closyr.ops.eval :as ops-eval] + [closyr.ops :as ops] + [closyr.ops.initialize :as ops-init]) + (:import [org.matheclipse.core.expression F])) + +(defn benchmark-scoring [] + (let [x ops-common/sym-x + expr (F/Plus (F/Sin x) (F/Times x x)) + pheno (ops-common/->phenotype x expr nil) + + ;; Create test data + xs (vec (range 0.1 10.0 0.1)) ; 100 points + ys (mapv #(+ (Math/sin %) (* % %)) xs) + n (count xs) + + xs-list (ops-common/exprs->exprs-list (ops-common/doubles->exprs xs)) + run-args {:input-xs-list xs-list :input-xs-count n + :input-xs-vec xs :input-ys-vec ys} + run-config {:max-leafs 40} + + ;; Pre-compute f(x) for scoring benchmarks + f-of-xs (ops-eval/eval-vec-pheno pheno run-args)] + + (println "\n=== Scoring Benchmark ===\n") + (println "Points:" n) + (println "f(x) sample:" (take 3 f-of-xs)) + + ;; Warmup + (dotimes [_ 100] + (ops/compute-score-from-actuals-and-expecteds pheno f-of-xs ys 5)) + + ;; Current implementation + (println "\n1. Current compute-score (10000x):") + (time (dotimes [_ 10000] + (ops/compute-score-from-actuals-and-expecteds pheno f-of-xs ys 5))) + + ;; Primitive array version + (println "\n2. Primitive array version (10000x):") + (let [^doubles ys-arr (double-array ys) + ^doubles fx-arr (double-array f-of-xs) + max-resid (double 1000000)] + (time (dotimes [_ 10000] + (let [n (alength ys-arr) + ;; Compute sum and max in single pass + [sum-resid max-resid-val] + (loop [i (int 0), sum (double 0.0), mx (double 0.0)] + (if (< i n) + (let [expected (aget ys-arr i) + actual (aget fx-arr i) + resid (if (Double/isNaN actual) + max-resid + (Math/abs (- expected actual))) + resid (Math/min max-resid resid)] + (recur (unchecked-inc-int i) + (+ sum resid) + (Math/max mx resid))) + [sum mx]))] + (* -1.0 (+ (* 2.0 (/ sum-resid n)) max-resid-val)))))) + + ;; Just the residual computation part + (println "\n3. Just map compute-residual (10000x):") + (time (dotimes [_ 10000] + (map ops/compute-residual ys f-of-xs))) + + ;; Realized map + (println "\n4. Realized map compute-residual (10000x):") + (time (dotimes [_ 10000] + (doall (map ops/compute-residual ys f-of-xs)))) + + ;; reduce + and reduce max + (println "\n5. reduce + and reduce max (10000x):") + (let [resids (mapv #(Math/abs (- %1 %2)) ys f-of-xs)] + (time (dotimes [_ 10000] + (let [s (reduce + 0.0 resids) + m (reduce max resids)] + (+ s m))))) + + ;; areduce version + (println "\n6. areduce sum and max (10000x):") + (let [^doubles resids-arr (double-array (mapv #(Math/abs (- %1 %2)) ys f-of-xs))] + (time (dotimes [_ 10000] + (let [s (areduce resids-arr i ret 0.0 (+ ret (aget resids-arr i))) + m (areduce resids-arr i ret 0.0 (Math/max ret (aget resids-arr i)))] + (+ s m))))) + + (println "\n=== Done ==="))) + +(benchmark-scoring) diff --git a/perf-profile/score_only_bench.clj b/perf-profile/score_only_bench.clj new file mode 100644 index 00000000..20bb2eaf --- /dev/null +++ b/perf-profile/score_only_bench.clj @@ -0,0 +1,43 @@ +(ns score-only-bench + "Benchmark just the score-fn to isolate residual computation" + (:require [closyr.ops.common :as ops-common] + [closyr.ops.eval :as ops-eval] + [closyr.ops :as ops]) + (:import [org.matheclipse.core.expression F])) + +(defn bench-score-fn [n-points n-calls use-arr?] + (let [x ops-common/sym-x + expr (F/Plus (F/Sin x) (F/Times x x)) + pheno (ops-common/->phenotype x expr nil) + xs (vec (range 0.1 (+ 0.1 (* n-points 0.1)) 0.1)) + ys (mapv #(+ (Math/sin %) (* % %)) xs) + xs-list (ops-common/exprs->exprs-list (ops-common/doubles->exprs xs)) + run-args (cond-> {:input-xs-list xs-list + :input-xs-count (count xs) + :input-xs-vec xs + :input-ys-vec ys} + use-arr? (assoc :input-ys-arr (double-array ys))) + run-config {:max-leafs 40}] + + ;; Warmup + (dotimes [_ 100] + (ops/score-fn run-args run-config pheno)) + + ;; Timed + (let [start (System/nanoTime)] + (dotimes [_ n-calls] + (ops/score-fn run-args run-config pheno)) + (/ (- (System/nanoTime) start) 1e6)))) + +(println "\n=== Score Function Benchmark (isolating residual computation) ===\n") + +(doseq [n-points [50 100 200 500]] + (let [n-calls 1000 + time-without (bench-score-fn n-points n-calls false) + time-with (bench-score-fn n-points n-calls true) + speedup (/ time-without time-with)] + (printf "Points: %3d | WITHOUT arr: %7.1f ms | WITH arr: %7.1f ms | Speedup: %.2fx\n" + n-points time-without time-with speedup))) + +(println "\n=== Done ===") +(System/exit 0) diff --git a/project.clj b/project.clj index b52ec81b..20b6a006 100644 --- a/project.clj +++ b/project.clj @@ -1,50 +1,99 @@ (defproject closyr "0.1.0-SNAPSHOT" - :description "FIXME: write description" - :url "http://example.com/FIXME" + :description "Symbolic Regression in Clojure" + :url "https://github.com/ogeagla/closyr" :license {:name "EPL-2.0 OR GPL-2.0-or-later WITH Classpath-exception-2.0" :url "https://www.eclipse.org/legal/epl-2.0/"} - :dependencies [[org.clojure/clojure "1.11.1"] - [org.clojure/core.async "1.6.681"] - [metosin/malli "0.14.0"] + :dependencies [[org.clojure/clojure "1.12.4"] + [org.clojure/core.async "1.8.741"] + [metosin/malli "0.20.0"] - [org.clojure/tools.cli "1.1.230"] + [org.clojure/tools.cli "1.3.250"] - [org.clojure/data.csv "1.1.0"] + [org.clojure/data.csv "1.1.1"] [seesaw/seesaw "1.5.0"] [flames "0.5.0"] - [org.knowm.xchart/xchart "3.8.7"] + [org.knowm.xchart/xchart "3.8.8"] ;; [com.github.weisj/darklaf-core "3.0.2"] [io.github.vincenzopalazzo/material-ui-swing "1.1.4"] [io.github.material-ui-swing/DarkStackOverflowTheme "0.0.1-rc3"] - [ch.qos.logback/logback-classic "1.5.0"] - [org.slf4j/jcl-over-slf4j "2.0.12"] + [ch.qos.logback/logback-classic "1.5.24"] + [org.slf4j/jcl-over-slf4j "2.0.17"] - [org.slf4j/slf4j-api "2.0.12"] - [org.apache.logging.log4j/log4j-core "2.22.1"] - [org.apache.logging.log4j/log4j-api "2.22.1"] + [org.slf4j/slf4j-api "2.0.17"] + [org.apache.logging.log4j/log4j-core "2.25.3"] + [org.apache.logging.log4j/log4j-api "2.25.3"] [org.matheclipse/matheclipse-core "3.1.0-SNAPSHOT" :exclusions [org.slf4j/slf4j-api com.fasterxml.jackson.core/jackson-core]] [org.matheclipse/matheclipse-gpl "3.1.0-SNAPSHOT" - :exclusions [org.slf4j/slf4j-api com.fasterxml.jackson.core/jackson-core]]] + :exclusions [org.slf4j/slf4j-api com.fasterxml.jackson.core/jackson-core]] + + ;; Web server + [ring/ring-core "1.15.3"] + [ring/ring-jetty-adapter "1.15.3"] + [ring/ring-defaults "0.7.0"] + [metosin/reitit "0.10.0"] + [selmer "1.12.70"] + [cheshire "6.1.0"]] :repositories {"snapshots-repo" {:url "https://oss.sonatype.org/content/repositories/snapshots" :releases false :snapshots true}} + :java-source-paths ["src/main/java"] + :java-test-paths ["test"] :main ^:skip-aot closyr.core :target-path "target/%s" + + ;; JVM tuning for better GA performance + :jvm-opts ["-Xms4g" ; Initial heap = max (avoid resizing) + "-Xmx4g" ; Max heap size + + + ;"-XX:+UseG1GC" ; G1 garbage collector (better for large heaps) + + ;"-XX:+UseZGC" + ;"-XX:+ZGenerational" + + "-XX:+UseParallelGC" + + + "-XX:MaxGCPauseMillis=100" ; Target max GC pause + "-XX:+UseStringDeduplication" ; Reduce memory for duplicate strings + "-XX:+AlwaysPreTouch" ; Pre-touch memory pages at startup + "-XX:+UseNUMA" ; Optimize for NUMA architectures + "-XX:+OptimizeStringConcat" ; Optimize string concatenation + "-XX:AutoBoxCacheMax=20000" ; Cache more Integer objects (reduce boxing overhead) + "-XX:+DisableExplicitGC"] ; Ignore System.gc() calls + ;; Note: direct-linking only in uberjar profile (breaks dynamic var rebinding in tests) + + ;; AOT compile API namespaces for Java interop + :aot [closyr.api.types + closyr.api.finder] + :profiles {:uberjar {:aot :all :manifest {"Multi-Release" true} - :jvm-opts ["-Dclojure.compiler.direct-linking=true"]}} + :jvm-opts ["-Dclojure.compiler.direct-linking=true"]} + :test {:dependencies [[org.junit.jupiter/junit-jupiter-api "6.0.2"] + [org.junit.jupiter/junit-jupiter-engine "6.0.2"] + [org.junit.platform/junit-platform-launcher "6.0.2"] + [org.junit.platform/junit-platform-console-standalone "6.0.2"]]}} + + :cloverage {:ns-exclude-regex [#"closyr\.ops\.common" + #"closyr\.ops\.eval" + #"closyr\.ops\.initialize" + #"closyr\.ops\.modify" + #"closyr\.ops" + #"closyr\.ui\." + #"closyr\.util\.spec"]} :plugins [[lein-cloverage "1.2.4"] [lein-vanity "0.2.0"] [lein-nomis-ns-graph "0.14.6"] [lein-ancient "0.7.0"] - [jonase/eastwood "1.4.2"] - [lein-kibit "0.1.8"] + [jonase/eastwood "1.4.3"] + [lein-kibit "0.1.11"] [lein-bikeshed "0.5.2"] [venantius/yagni "0.1.7"] [lein-check-namespace-decls "1.0.4"] diff --git a/resources/logback.xml b/resources/logback.xml index 35711779..ab9334e4 100644 --- a/resources/logback.xml +++ b/resources/logback.xml @@ -17,6 +17,8 @@ + + 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} +
+
+ + + + ${job.formula} +
+
+ ${labelBadge} + ${depthBadge} + ${improvementBadge} + ${statusBadge} + ${scoringBadge} + ${simplicityBadge} + ${datasetBadge} + ${childBadge} +
+
+ ${formatCompactHistoryScores(job)} + · + ${job.xs.length} pts${durationInfo}${iterationInfo} + · + ${job.timestamp} + ${sparkline} +
+
+ + + + ${children.length > 0 ? ` + + ` : ''} + +
+
+ +
`; + + // 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:
+
+
${formula}
+ +
+
+
+ +
+
+ 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

+
+
    +
  1. + Provide your data - + Enter X and Y values, upload a CSV file, or use a preset dataset. +
  2. +
  3. + Configure the solver - + Set the number of iterations, population size, and other parameters. +
  4. +
  5. + Watch it evolve - + The genetic algorithm will breed and mutate mathematical expressions. +
  6. +
  7. + Get your formula - + Receive the best-fit symbolic expression for your data. +
  8. +
+
+
+{% 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

+ +
+ +
+

Input Data

+ +
+ +
+ + +
+ + + + + +
+ + +
+ + +
+ + +
+ + +
+ + +
+
+ + + + +
+ +
+
+
+ + +
+ +
+ +
+
+ +
+ + +

Configuration

+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + + + Fitness scoring for evolution + +
+
+ + + + Preference for simpler formulas + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ + +
+ +
+
+
+ + +
+
+

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); + } +}