diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..210b3f6
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,27 @@
+name: CI
+
+on:
+ push:
+ branches: [ main ]
+ pull_request:
+ branches: [ main ]
+
+jobs:
+ build-and-test:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up JDK 11
+ uses: actions/setup-java@v4
+ with:
+ distribution: 'temurin'
+ java-version: '11'
+
+ - name: Build
+ run: ant compile
+
+ - name: Test
+ run: ant test
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
new file mode 100644
index 0000000..c93ddcb
--- /dev/null
+++ b/.github/workflows/docs.yml
@@ -0,0 +1,48 @@
+name: Deploy Docs
+
+on:
+ push:
+ branches: [ main ]
+ paths:
+ - 'docsite/**'
+
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+concurrency:
+ group: pages
+ cancel-in-progress: true
+
+jobs:
+ deploy:
+ runs-on: ubuntu-latest
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.x'
+
+ - name: Install MkDocs
+ run: pip install mkdocs
+
+ - name: Build site
+ run: mkdocs build
+ working-directory: docsite
+
+ - name: Upload artifact
+ uses: actions/upload-pages-artifact@v3
+ with:
+ path: docsite/site
+
+ - name: Deploy to GitHub Pages
+ id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/build.xml b/build.xml
index eb7fd18..c37aaa8 100644
--- a/build.xml
+++ b/build.xml
@@ -50,20 +50,57 @@
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docsite/docs/module-developer-guide.md b/docsite/docs/module-developer-guide.md
new file mode 100644
index 0000000..136a6df
--- /dev/null
+++ b/docsite/docs/module-developer-guide.md
@@ -0,0 +1,301 @@
+# Module Developer Guide
+
+This document describes the internals of the QIT module framework for developers
+who need to extend, maintain, or understand the codebase. For a user-facing
+overview, see [modules.md](modules.md).
+
+## Architecture Overview
+
+The module framework has four layers:
+
+1. **Annotations** (`qit.base.annot.*`) — declarative metadata on classes and fields
+2. **Module interface** (`qit.base.Module`) — the contract every module implements
+3. **Reflection engine** (`qit.base.utils.ModuleUtils`) — discovers modules, extracts field metadata, validates, serializes
+4. **CLI binding** (`qit.base.cli.*`) — wraps modules for command-line execution with auto-generated help
+
+```
+ ┌──────────────┐
+ │ QitMain │ entry point, module discovery
+ └──────┬───────┘
+ │
+ ┌──────▼───────┐
+ │ CliModule │ CLI wrapper, arg parsing, I/O
+ └──────┬───────┘
+ │
+ ┌──────▼───────┐
+ │ ModuleUtils │ reflection, validation, field access
+ └──────┬───────┘
+ │
+ ┌─────────▼─────────┐
+ │ Module + Annots │ user-written module code
+ └───────────────────┘
+```
+
+## Design Philosophy
+
+QIT modules are **pure data transforms**: they read from input fields, compute a
+result, and write to output fields. A module should not manage file discovery,
+iterate over subjects, handle dependencies between processing steps, or
+implement retry logic. These concerns belong to external orchestration tools.
+
+This is a deliberate design choice. By keeping modules stateless and
+single-purpose, they remain composable across many different execution contexts:
+
+- **Command line** — chain modules with shell scripts or Makefiles
+- **LONI Pipeline** — QIT modules integrate directly with the
+ [LONI Pipeline](http://pipeline.loni.usc.edu) workflow engine, which handles
+ execution graphs, parallelism, and provenance tracking
+- **Workflow managers** — tools like Nextflow, Snakemake, or Nipype can invoke
+ `qit ...` as a process step, managing dependencies and scheduling
+- **qitview** — the interactive viewer wraps modules in a GUI, letting users
+ apply transforms to loaded data without writing scripts
+
+Workflow orchestration tools evolve independently and have their own strengths
+(DAG scheduling, cluster support, checkpointing, provenance). By not
+reimplementing these capabilities inside QIT, modules stay simple, testable, and
+durable — they will work with whatever orchestration tools exist today or in the
+future.
+
+**Rule of thumb:** if you find yourself adding loops over subjects, file
+globbing, or conditional branching inside a module, that logic should live in an
+external script or workflow definition instead.
+
+## Annotations Reference
+
+All annotations are in `qit/base/annot/`. All use `@Retention(RUNTIME)` and
+`@Inherited`.
+
+### Class-level annotations
+
+| Annotation | Target | Purpose |
+|---|---|---|
+| `@ModuleDescription("...")` | Class or Field | Plain-text description shown in help |
+| `@ModuleAuthor("...")` | Class | Author attribution |
+| `@ModuleCitation("...")` | Class | Publication to cite when using the module |
+| `@ModuleUnlisted` | Class | Hides the module from `--list` and help output |
+
+### Field-level annotations
+
+Every annotated field must have exactly one role annotation:
+
+| Annotation | Role | Typical types |
+|---|---|---|
+| `@ModuleInput` | Data consumed by the module | Dataset subclasses: `Volume`, `Mask`, `Curves`, `Mesh`, `Vects`, `Table`, `Matrix`, `Affine`, `Gradients`, `Deformation`, `Neuron`, `Solids` |
+| `@ModuleOutput` | Data produced by the module | Same Dataset subclasses |
+| `@ModuleParameter` | Configuration value | `Double`, `Integer`, `String`, `Boolean`, `boolean`, enums |
+
+Field modifiers (combinable with a role annotation):
+
+| Annotation | Effect |
+|---|---|
+| `@ModuleOptional` | Field may be null; not required on the command line |
+| `@ModuleAdvanced` | Grouped separately in help output; not typically modified |
+| `@ModuleExpert` | Only visible when `--expert` flag is passed |
+
+### Validation rules
+
+`ModuleUtils.validate()` enforces:
+
+- Each annotated field has exactly one role (`@ModuleInput` XOR `@ModuleParameter` XOR `@ModuleOutput`)
+- `@ModuleOutput` fields are not marked `@ModuleAdvanced`
+- Input/output field types are valid Dataset subclasses
+
+## Module Interface
+
+```java
+public interface Module {
+ Module run() throws IOException;
+}
+```
+
+Modules must:
+
+- Implement a public no-argument constructor
+- Declare annotated `public` fields for inputs, parameters, and outputs
+- Implement `run()` which reads from input fields, writes to output fields, and returns `this`
+
+The framework populates input and parameter fields before calling `run()`, and
+reads output fields after it returns.
+
+## How Module Discovery Works
+
+`QitMain.main()` → `ModuleUtils.list()`:
+
+1. Uses `org.reflections.Reflections` to scan the `"qit"` package at runtime
+2. Finds all classes implementing `Module`
+3. Filters out inner classes and `@ModuleUnlisted` classes
+4. Maps simple class names (e.g. `VolumeThreshold`) to classes
+5. Also discovers `CliMain` implementations for batch processing tools
+
+A user invokes a module by name:
+```
+qit VolumeThreshold --input in.nii.gz --output out.nii.gz
+```
+
+`QitMain` looks up `VolumeThreshold`, wraps it in `CliModule`, and calls `run()`.
+
+## CLI Binding Flow
+
+`CliModule` is the adapter between `Module` and the command line:
+
+### 1. Build CLI specification (`cli()` method)
+
+For each field returned by `ModuleUtils.fields(module)`:
+
+- Check `@ModuleExpert` visibility (skip if not in expert mode)
+- Determine role from `@ModuleInput` / `@ModuleParameter` / `@ModuleOutput`
+- Read `@ModuleDescription` for help text
+- Read `@ModuleOptional` / `@ModuleAdvanced` for categorization
+- For `boolean` fields: create a flag (no argument value needed)
+- For `enum` fields: auto-generate option list from enum constants
+- For Dataset fields: use the type name as the argument placeholder
+- Build a `CliOption` and add it to `CliSpecification`
+
+### 2. Parse arguments
+
+`CliValues` parses `--key value` pairs and positional arguments from `String[] args`.
+
+### 3. Read inputs (`read()` method)
+
+For each input/parameter field:
+
+- Get the string value from `CliValues`
+- For Dataset types: call the type's static `read(filename)` method via reflection
+- For primitives: parse from string (`Double.valueOf()`, etc.)
+- For enums: `Enum.valueOf()`
+- For booleans: presence of the flag means `true`
+
+### 4. Execute
+
+Call `module.run()`.
+
+### 5. Write outputs (`write()` method)
+
+For each `@ModuleOutput` field:
+
+- Get the filename from `CliValues`
+- Call the Dataset's `write(filename)` method via reflection
+
+## Writing a New Module
+
+### Minimal example
+
+```java
+@ModuleDescription("Scale a volume by a constant factor")
+@ModuleAuthor("Your Name")
+public class VolumeScale implements Module
+{
+ @ModuleInput
+ @ModuleDescription("input volume")
+ public Volume input;
+
+ @ModuleParameter
+ @ModuleDescription("scale factor")
+ public Double factor = 1.0;
+
+ @ModuleOutput
+ @ModuleDescription("output volume")
+ public Volume output;
+
+ @Override
+ public VolumeScale run()
+ {
+ this.output = this.input.copy();
+ // ... scale voxel values by this.factor ...
+ return this;
+ }
+}
+```
+
+This automatically provides:
+
+- `qit VolumeScale --help` with formatted help text
+- `qit VolumeScale --input in.nii.gz --factor 2.0 --output out.nii.gz`
+- Availability in `qitview` module panel
+- JSON serialization of parameters via `--save`/`--load`
+
+### Conventions
+
+- Place modules in `qit/data/modules//` (e.g. `volume/`, `mask/`, `curves/`)
+- Class name should be `` (e.g. `VolumeThreshold`, `MaskInvert`, `CurvesTransform`)
+- Always set default values for `@ModuleParameter` fields
+- Mark non-essential inputs with `@ModuleOptional`
+- Return `this` from `run()`
+
+### Using @ModuleOptional inputs
+
+```java
+@ModuleInput
+@ModuleOptional
+@ModuleDescription("optional mask to restrict processing")
+public Mask mask;
+
+public MyModule run()
+{
+ for (Sample sample : this.input.getSampling())
+ {
+ if (this.input.valid(sample, this.mask)) // mask can be null
+ {
+ // process voxel
+ }
+ }
+ return this;
+}
+```
+
+### Using enum parameters
+
+```java
+public enum InterpolationType { NEAREST, LINEAR, CUBIC }
+
+@ModuleParameter
+@ModuleDescription("interpolation method")
+public InterpolationType interp = InterpolationType.LINEAR;
+```
+
+The CLI will automatically show: `--interp (Options: NEAREST, LINEAR, CUBIC) (Default: LINEAR)`
+
+## Batch Processing Tools
+
+For operations across multiple subjects/files, create a `CliMain` implementation
+in `qit/main/`:
+
+```java
+@ModuleDescription("Run VolumeThreshold on many subjects")
+@ModuleAuthor("Your Name")
+public class VolumeThresholdBatch implements CliMain
+{
+ public void execute(CliValues args) throws Exception
+ {
+ // Custom batch logic using args.keyed("--input"), etc.
+ }
+}
+```
+
+These are discovered alongside Modules and invoked the same way:
+`qit VolumeThresholdBatch --help`
+
+## Module Serialization
+
+Modules can be saved and loaded as JSON:
+
+```
+qit VolumeThreshold --input in.nii.gz --threshold 0.3 --output out.nii.gz --save params.json
+qit VolumeThreshold --load params.json --input other.nii.gz --output other_out.nii.gz
+```
+
+`ModuleUtils.write()` serializes annotated field values to JSON.
+`ModuleUtils.read()` deserializes and populates fields.
+
+## Key Source Files
+
+| File | Role |
+|---|---|
+| `qit/base/Module.java` | Module interface (10 lines) |
+| `qit/base/annot/*.java` | All annotation definitions |
+| `qit/base/utils/ModuleUtils.java` | Reflection engine: discovery, validation, field access, serialization |
+| `qit/base/cli/CliModule.java` | CLI adapter: arg parsing, file I/O, help generation |
+| `qit/base/cli/CliSpecification.java` | Help text and Markdown generation |
+| `qit/base/cli/CliOption.java` | Single CLI option metadata |
+| `qit/base/cli/CliValues.java` | Parsed command-line arguments |
+| `qit/main/QitMain.java` | Entry point, module discovery and dispatch |
diff --git a/docsite/docs/modules.md b/docsite/docs/modules.md
index 48261df..9340fd1 100644
--- a/docsite/docs/modules.md
+++ b/docsite/docs/modules.md
@@ -51,6 +51,19 @@ script, which are not typically integrated into data viewers or support many
file formats. Finally, each `Module` also supports meta-data that
includes the author, associated publications, and plain english descriptions.
+## How do modules fit into a workflow?
+
+Each module is a self-contained data transform — it reads inputs, processes
+them, and writes outputs. Modules do not manage multi-step workflows themselves.
+Instead, you compose modules into pipelines using external orchestration tools:
+
+- **Shell scripts** or **Makefiles** for simple sequential pipelines
+- **[LONI Pipeline](http://pipeline.loni.usc.edu)** for visual workflow design with built-in parallelism and provenance — QIT modules integrate directly with LONI Pipeline
+- **Nextflow**, **Snakemake**, or **Nipype** for reproducible neuroimaging workflows with cluster support
+
+This keeps each module simple and reusable across different execution
+environments.
+
## What makes up a `Module`?
Each `Module` has some number of fields with annotations to indicate
diff --git a/docsite/mkdocs.yml b/docsite/mkdocs.yml
index 1fdf10c..81d38cf 100644
--- a/docsite/mkdocs.yml
+++ b/docsite/mkdocs.yml
@@ -30,6 +30,7 @@ nav:
- Colormaps: colormaps.md
- Datasets: datasets.md
- Modules: modules.md
+ - Module Developer Guide: module-developer-guide.md
- Models: models.md
- Tutorials:
- Workflow Tutorial: workflow.md
diff --git a/lib/test-jars/junit-platform-console-standalone-1.10.2.jar b/lib/test-jars/junit-platform-console-standalone-1.10.2.jar
new file mode 100644
index 0000000..7ca10e6
Binary files /dev/null and b/lib/test-jars/junit-platform-console-standalone-1.10.2.jar differ
diff --git a/test/qit/base/utils/ModuleValidationTest.java b/test/qit/base/utils/ModuleValidationTest.java
new file mode 100644
index 0000000..763a06c
--- /dev/null
+++ b/test/qit/base/utils/ModuleValidationTest.java
@@ -0,0 +1,39 @@
+package qit.base.utils;
+
+import org.junit.jupiter.api.DynamicTest;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestFactory;
+import qit.base.Module;
+
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Stream;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class ModuleValidationTest
+{
+ @Test
+ void moduleDiscoveryFindsModules()
+ {
+ Map> modules = ModuleUtils.listedClasses();
+ assertTrue(modules.size() > 100,
+ "Expected 100+ listed modules, found " + modules.size());
+ }
+
+ @TestFactory
+ Stream allModulesPassValidation()
+ {
+ List modules = ModuleUtils.list();
+
+ return modules.stream().map(module ->
+ DynamicTest.dynamicTest(module.getClass().getSimpleName(), () ->
+ {
+ List errors = ModuleUtils.report(module);
+ assertTrue(errors.isEmpty(),
+ module.getClass().getSimpleName() + " has validation errors:\n"
+ + String.join("\n", errors));
+ })
+ );
+ }
+}
diff --git a/test/qit/data/datasets/MatrixTest.java b/test/qit/data/datasets/MatrixTest.java
new file mode 100644
index 0000000..1591bdf
--- /dev/null
+++ b/test/qit/data/datasets/MatrixTest.java
@@ -0,0 +1,592 @@
+package qit.data.datasets;
+
+import org.junit.jupiter.api.Test;
+import static org.junit.jupiter.api.Assertions.*;
+
+class MatrixTest
+{
+ private static final double EPS = 1e-9;
+
+ // ===== construction =====
+
+ @Test
+ void constructRowsCols()
+ {
+ Matrix m = new Matrix(3, 4);
+ assertEquals(3, m.rows());
+ assertEquals(4, m.cols());
+ assertEquals(0.0, m.get(0, 0), EPS);
+ }
+
+ @Test
+ void constructWithValue()
+ {
+ Matrix m = new Matrix(2, 3, 5.0);
+ assertEquals(2, m.rows());
+ assertEquals(3, m.cols());
+ for (int i = 0; i < 2; i++)
+ {
+ for (int j = 0; j < 3; j++)
+ {
+ assertEquals(5.0, m.get(i, j), EPS);
+ }
+ }
+ }
+
+ @Test
+ void constructFromArray()
+ {
+ double[][] data = {{1, 2}, {3, 4}};
+ Matrix m = new Matrix(data);
+ assertEquals(2, m.rows());
+ assertEquals(2, m.cols());
+ assertEquals(1.0, m.get(0, 0), EPS);
+ assertEquals(4.0, m.get(1, 1), EPS);
+ }
+
+ // ===== accessors =====
+
+ @Test
+ void setAndGet()
+ {
+ Matrix m = new Matrix(2, 2);
+ m.set(0, 0, 1.0);
+ m.set(0, 1, 2.0);
+ m.set(1, 0, 3.0);
+ m.set(1, 1, 4.0);
+ assertEquals(1.0, m.get(0, 0), EPS);
+ assertEquals(2.0, m.get(0, 1), EPS);
+ assertEquals(3.0, m.get(1, 0), EPS);
+ assertEquals(4.0, m.get(1, 1), EPS);
+ }
+
+ @Test
+ void isSquare()
+ {
+ assertTrue(new Matrix(3, 3).isSquare());
+ assertFalse(new Matrix(2, 3).isSquare());
+ }
+
+ @Test
+ void compatible()
+ {
+ Matrix a = new Matrix(2, 3);
+ Matrix b = new Matrix(2, 3);
+ Matrix c = new Matrix(3, 2);
+ assertTrue(a.compatible(b));
+ assertFalse(a.compatible(c));
+ }
+
+ // ===== row/column access =====
+
+ @Test
+ void getRowAndColumn()
+ {
+ Matrix m = new Matrix(new double[][]{{1, 2, 3}, {4, 5, 6}});
+ Vect row = m.getRow(0);
+ assertEquals(3, row.size());
+ assertEquals(1.0, row.get(0), EPS);
+ assertEquals(2.0, row.get(1), EPS);
+ assertEquals(3.0, row.get(2), EPS);
+
+ Vect col = m.getColumn(1);
+ assertEquals(2, col.size());
+ assertEquals(2.0, col.get(0), EPS);
+ assertEquals(5.0, col.get(1), EPS);
+ }
+
+ @Test
+ void setRowAndColumn()
+ {
+ Matrix m = new Matrix(2, 3);
+ m.setRow(0, new Vect(new double[]{1, 2, 3}));
+ assertEquals(1.0, m.get(0, 0), EPS);
+ assertEquals(2.0, m.get(0, 1), EPS);
+ assertEquals(3.0, m.get(0, 2), EPS);
+
+ m.setColumn(1, new Vect(new double[]{10, 20}));
+ assertEquals(10.0, m.get(0, 1), EPS);
+ assertEquals(20.0, m.get(1, 1), EPS);
+ }
+
+ // ===== setAll =====
+
+ @Test
+ void setAll()
+ {
+ Matrix m = new Matrix(2, 2);
+ m.setAll(7.0);
+ assertEquals(7.0, m.get(0, 0), EPS);
+ assertEquals(7.0, m.get(1, 1), EPS);
+ }
+
+ @Test
+ void setAllDiag()
+ {
+ Matrix m = new Matrix(3, 3);
+ m.setAllDiag(1.0);
+ assertEquals(1.0, m.get(0, 0), EPS);
+ assertEquals(1.0, m.get(1, 1), EPS);
+ assertEquals(1.0, m.get(2, 2), EPS);
+ assertEquals(0.0, m.get(0, 1), EPS);
+ }
+
+ // ===== scalar arithmetic =====
+
+ @Test
+ void timesScalar()
+ {
+ Matrix m = new Matrix(new double[][]{{1, 2}, {3, 4}});
+ Matrix result = m.times(2.0);
+ assertEquals(2.0, result.get(0, 0), EPS);
+ assertEquals(8.0, result.get(1, 1), EPS);
+ // original unchanged
+ assertEquals(1.0, m.get(0, 0), EPS);
+ }
+
+ @Test
+ void timesEqualsScalar()
+ {
+ Matrix m = new Matrix(new double[][]{{1, 2}, {3, 4}});
+ m.timesEquals(3.0);
+ assertEquals(3.0, m.get(0, 0), EPS);
+ assertEquals(12.0, m.get(1, 1), EPS);
+ }
+
+ // ===== matrix arithmetic =====
+
+ @Test
+ void plusMatrix()
+ {
+ Matrix a = new Matrix(new double[][]{{1, 2}, {3, 4}});
+ Matrix b = new Matrix(new double[][]{{5, 6}, {7, 8}});
+ Matrix c = a.plus(b);
+ assertEquals(6.0, c.get(0, 0), EPS);
+ assertEquals(8.0, c.get(0, 1), EPS);
+ assertEquals(10.0, c.get(1, 0), EPS);
+ assertEquals(12.0, c.get(1, 1), EPS);
+ }
+
+ @Test
+ void minusMatrix()
+ {
+ Matrix a = new Matrix(new double[][]{{5, 6}, {7, 8}});
+ Matrix b = new Matrix(new double[][]{{1, 2}, {3, 4}});
+ Matrix c = a.minus(b);
+ assertEquals(4.0, c.get(0, 0), EPS);
+ assertEquals(4.0, c.get(0, 1), EPS);
+ assertEquals(4.0, c.get(1, 0), EPS);
+ assertEquals(4.0, c.get(1, 1), EPS);
+ }
+
+ @Test
+ void timesMatrix()
+ {
+ // [[1,2],[3,4]] * [[5,6],[7,8]] = [[19,22],[43,50]]
+ Matrix a = new Matrix(new double[][]{{1, 2}, {3, 4}});
+ Matrix b = new Matrix(new double[][]{{5, 6}, {7, 8}});
+ Matrix c = a.times(b);
+ assertEquals(19.0, c.get(0, 0), EPS);
+ assertEquals(22.0, c.get(0, 1), EPS);
+ assertEquals(43.0, c.get(1, 0), EPS);
+ assertEquals(50.0, c.get(1, 1), EPS);
+ }
+
+ @Test
+ void timesVect()
+ {
+ // [[1,2],[3,4]] * [5,6] = [17,39]
+ Matrix m = new Matrix(new double[][]{{1, 2}, {3, 4}});
+ Vect v = new Vect(new double[]{5, 6});
+ Vect result = m.times(v);
+ assertEquals(2, result.size());
+ assertEquals(17.0, result.get(0), EPS);
+ assertEquals(39.0, result.get(1), EPS);
+ }
+
+ @Test
+ void timesElem()
+ {
+ Matrix a = new Matrix(new double[][]{{1, 2}, {3, 4}});
+ Matrix b = new Matrix(new double[][]{{5, 6}, {7, 8}});
+ Matrix c = a.timesElem(b);
+ assertEquals(5.0, c.get(0, 0), EPS);
+ assertEquals(12.0, c.get(0, 1), EPS);
+ assertEquals(21.0, c.get(1, 0), EPS);
+ assertEquals(32.0, c.get(1, 1), EPS);
+ }
+
+ @Test
+ void plusScaledMatrix()
+ {
+ // a + s*b
+ Matrix a = new Matrix(new double[][]{{1, 0}, {0, 1}});
+ Matrix b = new Matrix(new double[][]{{2, 2}, {2, 2}});
+ Matrix c = a.plus(0.5, b);
+ assertEquals(2.0, c.get(0, 0), EPS);
+ assertEquals(1.0, c.get(0, 1), EPS);
+ assertEquals(1.0, c.get(1, 0), EPS);
+ assertEquals(2.0, c.get(1, 1), EPS);
+ }
+
+ // ===== row/col broadcast =====
+
+ @Test
+ void plusRows()
+ {
+ Matrix m = new Matrix(new double[][]{{1, 2}, {3, 4}});
+ Vect v = new Vect(new double[]{10, 20});
+ Matrix result = m.plusRows(v);
+ assertEquals(11.0, result.get(0, 0), EPS);
+ assertEquals(22.0, result.get(0, 1), EPS);
+ assertEquals(13.0, result.get(1, 0), EPS);
+ assertEquals(24.0, result.get(1, 1), EPS);
+ }
+
+ @Test
+ void minusRows()
+ {
+ Matrix m = new Matrix(new double[][]{{11, 22}, {13, 24}});
+ Vect v = new Vect(new double[]{10, 20});
+ Matrix result = m.minusRows(v);
+ assertEquals(1.0, result.get(0, 0), EPS);
+ assertEquals(2.0, result.get(0, 1), EPS);
+ }
+
+ @Test
+ void plusCols()
+ {
+ Matrix m = new Matrix(new double[][]{{1, 2}, {3, 4}});
+ Vect v = new Vect(new double[]{10, 20});
+ Matrix result = m.plusCols(v);
+ assertEquals(11.0, result.get(0, 0), EPS);
+ assertEquals(12.0, result.get(0, 1), EPS);
+ assertEquals(23.0, result.get(1, 0), EPS);
+ assertEquals(24.0, result.get(1, 1), EPS);
+ }
+
+ // ===== linear algebra (JAMA) =====
+
+ @Test
+ void transpose()
+ {
+ Matrix m = new Matrix(new double[][]{{1, 2, 3}, {4, 5, 6}});
+ Matrix t = m.transpose();
+ assertEquals(3, t.rows());
+ assertEquals(2, t.cols());
+ assertEquals(1.0, t.get(0, 0), EPS);
+ assertEquals(4.0, t.get(0, 1), EPS);
+ assertEquals(2.0, t.get(1, 0), EPS);
+ assertEquals(5.0, t.get(1, 1), EPS);
+ }
+
+ @Test
+ void transposeDoubleRoundtrip()
+ {
+ Matrix m = new Matrix(new double[][]{{1, 2}, {3, 4}});
+ Matrix tt = m.transpose().transpose();
+ for (int i = 0; i < 2; i++)
+ {
+ for (int j = 0; j < 2; j++)
+ {
+ assertEquals(m.get(i, j), tt.get(i, j), EPS);
+ }
+ }
+ }
+
+ @Test
+ void determinant2x2()
+ {
+ // det([[1,2],[3,4]]) = 1*4 - 2*3 = -2
+ Matrix m = new Matrix(new double[][]{{1, 2}, {3, 4}});
+ assertEquals(-2.0, m.det(), 1e-6);
+ }
+
+ @Test
+ void determinant3x3()
+ {
+ // det([[1,0,0],[0,2,0],[0,0,3]]) = 6
+ Matrix m = new Matrix(new double[][]{{1, 0, 0}, {0, 2, 0}, {0, 0, 3}});
+ assertEquals(6.0, m.det(), 1e-6);
+ }
+
+ @Test
+ void determinantIdentity()
+ {
+ Matrix id = new Matrix(3, 3);
+ id.setAllDiag(1.0);
+ assertEquals(1.0, id.det(), 1e-6);
+ }
+
+ @Test
+ void inverse()
+ {
+ Matrix m = new Matrix(new double[][]{{1, 2}, {3, 4}});
+ Matrix inv = m.inv();
+
+ // m * inv should be identity
+ Matrix product = m.times(inv);
+ assertEquals(1.0, product.get(0, 0), 1e-6);
+ assertEquals(0.0, product.get(0, 1), 1e-6);
+ assertEquals(0.0, product.get(1, 0), 1e-6);
+ assertEquals(1.0, product.get(1, 1), 1e-6);
+ }
+
+ @Test
+ void inverseRoundtrip()
+ {
+ Matrix m = new Matrix(new double[][]{{2, 1}, {5, 3}});
+ Matrix roundtrip = m.inv().inv();
+ for (int i = 0; i < 2; i++)
+ {
+ for (int j = 0; j < 2; j++)
+ {
+ assertEquals(m.get(i, j), roundtrip.get(i, j), 1e-6);
+ }
+ }
+ }
+
+ @Test
+ void trace()
+ {
+ Matrix m = new Matrix(new double[][]{{1, 2, 3}, {4, 5, 6}, {7, 8, 9}});
+ assertEquals(15.0, m.trace(), EPS);
+ }
+
+ @Test
+ void rank()
+ {
+ // identity has full rank
+ Matrix id = new Matrix(3, 3);
+ id.setAllDiag(1.0);
+ assertEquals(3, id.rank());
+
+ // zero matrix has rank 0
+ assertEquals(0, new Matrix(3, 3).rank());
+ }
+
+ @Test
+ void diag()
+ {
+ Matrix m = new Matrix(new double[][]{{1, 2, 3}, {4, 5, 6}, {7, 8, 9}});
+ Vect d = m.diag();
+ assertEquals(3, d.size());
+ assertEquals(1.0, d.get(0), EPS);
+ assertEquals(5.0, d.get(1), EPS);
+ assertEquals(9.0, d.get(2), EPS);
+ }
+
+ // ===== norms =====
+
+ @Test
+ void normF()
+ {
+ // Frobenius norm of [[1,2],[3,4]] = sqrt(1+4+9+16) = sqrt(30)
+ Matrix m = new Matrix(new double[][]{{1, 2}, {3, 4}});
+ assertEquals(Math.sqrt(30.0), m.normF(), 1e-6);
+ }
+
+ @Test
+ void sumsq()
+ {
+ Matrix m = new Matrix(new double[][]{{1, 2}, {3, 4}});
+ assertEquals(30.0, m.sumsq(), EPS);
+ }
+
+ @Test
+ void inner()
+ {
+ Matrix a = new Matrix(new double[][]{{1, 2}, {3, 4}});
+ Matrix b = new Matrix(new double[][]{{5, 6}, {7, 8}});
+ // inner = 1*5 + 2*6 + 3*7 + 4*8 = 5+12+21+32 = 70
+ assertEquals(70.0, a.inner(b), EPS);
+ }
+
+ // ===== statistics =====
+
+ @Test
+ void mean()
+ {
+ Matrix m = new Matrix(new double[][]{{1, 2}, {3, 4}});
+ assertEquals(2.5, m.mean(), EPS);
+ }
+
+ @Test
+ void meanRow()
+ {
+ // meanRow returns mean of each column (the "mean row")
+ Matrix m = new Matrix(new double[][]{{1, 4}, {3, 6}});
+ Vect mr = m.meanRow();
+ assertEquals(2, mr.size());
+ assertEquals(2.0, mr.get(0), EPS); // mean of column 0: (1+3)/2
+ assertEquals(5.0, mr.get(1), EPS); // mean of column 1: (4+6)/2
+ }
+
+ @Test
+ void meanColumn()
+ {
+ // meanColumn returns mean of each row
+ Matrix m = new Matrix(new double[][]{{1, 3}, {4, 6}});
+ Vect mc = m.meanColumn();
+ assertEquals(2, mc.size());
+ assertEquals(2.0, mc.get(0), EPS); // mean of row 0: (1+3)/2
+ assertEquals(5.0, mc.get(1), EPS); // mean of row 1: (4+6)/2
+ }
+
+ // ===== structural operations =====
+
+ @Test
+ void sub()
+ {
+ Matrix m = new Matrix(new double[][]{{1, 2, 3}, {4, 5, 6}, {7, 8, 9}});
+ Matrix s = m.sub(0, 1, 1, 2);
+ assertEquals(2, s.rows());
+ assertEquals(2, s.cols());
+ assertEquals(2.0, s.get(0, 0), EPS);
+ assertEquals(3.0, s.get(0, 1), EPS);
+ assertEquals(5.0, s.get(1, 0), EPS);
+ assertEquals(6.0, s.get(1, 1), EPS);
+ }
+
+ @Test
+ void catRows()
+ {
+ Matrix a = new Matrix(new double[][]{{1, 2}, {3, 4}});
+ Matrix b = new Matrix(new double[][]{{5}, {6}});
+ Matrix c = a.catRows(b);
+ assertEquals(2, c.rows());
+ assertEquals(3, c.cols());
+ assertEquals(5.0, c.get(0, 2), EPS);
+ assertEquals(6.0, c.get(1, 2), EPS);
+ }
+
+ @Test
+ void catCols()
+ {
+ Matrix a = new Matrix(new double[][]{{1, 2}, {3, 4}});
+ Matrix b = new Matrix(new double[][]{{5, 6}});
+ Matrix c = a.catCols(b);
+ assertEquals(3, c.rows());
+ assertEquals(2, c.cols());
+ assertEquals(5.0, c.get(2, 0), EPS);
+ assertEquals(6.0, c.get(2, 1), EPS);
+ }
+
+ @Test
+ void flatten()
+ {
+ Matrix m = new Matrix(new double[][]{{1, 2}, {3, 4}});
+ Vect f = m.flatten();
+ assertEquals(4, f.size());
+ assertEquals(1.0, f.get(0), EPS);
+ assertEquals(2.0, f.get(1), EPS);
+ assertEquals(3.0, f.get(2), EPS);
+ assertEquals(4.0, f.get(3), EPS);
+ }
+
+ @Test
+ void packRow()
+ {
+ Matrix m = new Matrix(new double[][]{{1, 2}, {3, 4}});
+ Vect p = m.packRow();
+ assertEquals(4, p.size());
+ // row-major: 1,2,3,4
+ assertEquals(1.0, p.get(0), EPS);
+ assertEquals(2.0, p.get(1), EPS);
+ assertEquals(3.0, p.get(2), EPS);
+ assertEquals(4.0, p.get(3), EPS);
+ }
+
+ @Test
+ void packColumn()
+ {
+ Matrix m = new Matrix(new double[][]{{1, 2}, {3, 4}});
+ Vect p = m.packColumn();
+ assertEquals(4, p.size());
+ // column-major: 1,3,2,4
+ assertEquals(1.0, p.get(0), EPS);
+ assertEquals(3.0, p.get(1), EPS);
+ assertEquals(2.0, p.get(2), EPS);
+ assertEquals(4.0, p.get(3), EPS);
+ }
+
+ // ===== element-wise transforms =====
+
+ @Test
+ void expAndLog()
+ {
+ Matrix m = new Matrix(new double[][]{{0, 1}, {2, 3}});
+ Matrix e = m.exp();
+ assertEquals(1.0, e.get(0, 0), 1e-6);
+ assertEquals(Math.E, e.get(0, 1), 1e-6);
+
+ // log(exp(m)) should round-trip
+ Matrix roundtrip = e.log();
+ for (int i = 0; i < 2; i++)
+ {
+ for (int j = 0; j < 2; j++)
+ {
+ assertEquals(m.get(i, j), roundtrip.get(i, j), 1e-6);
+ }
+ }
+ }
+
+ // ===== predicates =====
+
+ @Test
+ void nan()
+ {
+ Matrix good = new Matrix(new double[][]{{1, 2}, {3, 4}});
+ assertFalse(good.nan());
+
+ Matrix bad = new Matrix(new double[][]{{1, Double.NaN}, {3, 4}});
+ assertTrue(bad.nan());
+ }
+
+ @Test
+ void infinite()
+ {
+ Matrix good = new Matrix(new double[][]{{1, 2}, {3, 4}});
+ assertFalse(good.infinite());
+
+ Matrix bad = new Matrix(new double[][]{{1, Double.POSITIVE_INFINITY}, {3, 4}});
+ assertTrue(bad.infinite());
+ }
+
+ // ===== copy independence =====
+
+ @Test
+ void copy()
+ {
+ Matrix m = new Matrix(new double[][]{{1, 2}, {3, 4}});
+ Matrix c = m.copy();
+ assertEquals(m.get(0, 0), c.get(0, 0), EPS);
+ c.set(0, 0, 999.0);
+ assertEquals(1.0, m.get(0, 0), EPS);
+ }
+
+ @Test
+ void proto()
+ {
+ Matrix m = new Matrix(3, 4);
+ Matrix p = m.proto();
+ assertEquals(3, p.rows());
+ assertEquals(4, p.cols());
+ assertEquals(0.0, p.get(0, 0), EPS);
+ }
+
+ // ===== homogeneous =====
+
+ @Test
+ void hom()
+ {
+ Matrix m = new Matrix(new double[][]{{1, 0}, {0, 1}});
+ Matrix h = m.hom();
+ assertEquals(3, h.rows());
+ assertEquals(3, h.cols());
+ assertEquals(1.0, h.get(0, 0), EPS);
+ assertEquals(1.0, h.get(1, 1), EPS);
+ assertEquals(1.0, h.get(2, 2), EPS);
+ assertEquals(0.0, h.get(0, 2), EPS);
+ }
+}
diff --git a/test/qit/data/datasets/SchemaTest.java b/test/qit/data/datasets/SchemaTest.java
new file mode 100644
index 0000000..54bf3c2
--- /dev/null
+++ b/test/qit/data/datasets/SchemaTest.java
@@ -0,0 +1,175 @@
+package qit.data.datasets;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class SchemaTest
+{
+ // ===== construction =====
+
+ @Test
+ void constructEmpty()
+ {
+ Schema s = new Schema();
+ assertEquals(0, s.size());
+ }
+
+ @Test
+ void constructFromIterable()
+ {
+ Schema s = new Schema(Arrays.asList("x", "y", "z"));
+ assertEquals(3, s.size());
+ assertEquals("x", s.getField(0));
+ assertEquals("y", s.getField(1));
+ assertEquals("z", s.getField(2));
+ }
+
+ // ===== add / size =====
+
+ @Test
+ void addField()
+ {
+ Schema s = new Schema();
+ s.add("name");
+ s.add("age");
+ assertEquals(2, s.size());
+ }
+
+ @Test
+ void addFieldWithDefault()
+ {
+ Schema s = new Schema();
+ s.add("color", "red");
+ assertEquals("red", s.getDefault("color"));
+ assertEquals("red", s.getDefault(0));
+ }
+
+ @Test
+ void addDuplicateThrows()
+ {
+ Schema s = new Schema();
+ s.add("name");
+ assertThrows(RuntimeException.class, () -> s.add("name"));
+ }
+
+ // ===== hasField =====
+
+ @Test
+ void hasField()
+ {
+ Schema s = new Schema();
+ s.add("name");
+ assertTrue(s.hasField("name"));
+ assertFalse(s.hasField("age"));
+ }
+
+ // ===== getIndex =====
+
+ @Test
+ void getIndex()
+ {
+ Schema s = new Schema();
+ s.add("a");
+ s.add("b");
+ s.add("c");
+ assertEquals(0, s.getIndex("a"));
+ assertEquals(1, s.getIndex("b"));
+ assertEquals(2, s.getIndex("c"));
+ }
+
+ @Test
+ void getIndexMissingThrows()
+ {
+ Schema s = new Schema();
+ s.add("a");
+ assertThrows(RuntimeException.class, () -> s.getIndex("z"));
+ }
+
+ // ===== getField =====
+
+ @Test
+ void getField()
+ {
+ Schema s = new Schema();
+ s.add("first");
+ s.add("second");
+ assertEquals("first", s.getField(0));
+ assertEquals("second", s.getField(1));
+ }
+
+ // ===== getFields =====
+
+ @Test
+ void getFields()
+ {
+ Schema s = new Schema(Arrays.asList("a", "b", "c"));
+ List fields = s.getFields();
+ assertEquals(3, fields.size());
+ assertEquals("a", fields.get(0));
+ assertEquals("c", fields.get(2));
+ }
+
+ // ===== lock =====
+
+ @Test
+ void lock()
+ {
+ Schema s = new Schema();
+ assertFalse(s.locked());
+ s.lock();
+ assertTrue(s.locked());
+ }
+
+ // ===== copy =====
+
+ @Test
+ void copy()
+ {
+ Schema s = new Schema();
+ s.add("x");
+ s.add("y");
+ Schema c = s.copy();
+ assertEquals(s.size(), c.size());
+ assertEquals("x", c.getField(0));
+ assertEquals("y", c.getField(1));
+ }
+
+ @Test
+ void copyIndependence()
+ {
+ Schema s = new Schema();
+ s.add("x");
+ Schema c = s.copy();
+ c.add("y");
+ assertEquals(1, s.size());
+ assertEquals(2, c.size());
+ }
+
+ // ===== iterator =====
+
+ @Test
+ void iterator()
+ {
+ Schema s = new Schema(Arrays.asList("a", "b", "c"));
+ int count = 0;
+ for (String field : s)
+ {
+ count++;
+ }
+ assertEquals(3, count);
+ }
+
+ // ===== defaults =====
+
+ @Test
+ void defaultIsNullWhenNotSpecified()
+ {
+ Schema s = new Schema();
+ s.add("name");
+ assertNull(s.getDefault("name"));
+ }
+}
diff --git a/test/qit/data/datasets/TableTest.java b/test/qit/data/datasets/TableTest.java
new file mode 100644
index 0000000..17dcfef
--- /dev/null
+++ b/test/qit/data/datasets/TableTest.java
@@ -0,0 +1,356 @@
+package qit.data.datasets;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class TableTest
+{
+ // ===== construction =====
+
+ @Test
+ void constructEmpty()
+ {
+ Table t = new Table();
+ assertEquals(0, t.getNumRecords());
+ assertEquals(0, t.getNumFields());
+ }
+
+ @Test
+ void constructFromSchema()
+ {
+ Schema s = new Schema(Arrays.asList("name", "age"));
+ Table t = new Table(s);
+ assertEquals(0, t.getNumRecords());
+ assertEquals(2, t.getNumFields());
+ assertTrue(t.hasField("name"));
+ assertTrue(t.hasField("age"));
+ }
+
+ @Test
+ void constructFromIterable()
+ {
+ Table t = new Table(Arrays.asList("x", "y", "z"));
+ assertEquals(3, t.getNumFields());
+ }
+
+ // ===== addField =====
+
+ @Test
+ void addField()
+ {
+ Table t = new Table();
+ t.addField("name");
+ t.addField("value");
+ assertEquals(2, t.getNumFields());
+ assertTrue(t.hasField("name"));
+ assertTrue(t.hasField("value"));
+ }
+
+ @Test
+ void addFieldIdempotent()
+ {
+ Table t = new Table();
+ t.addField("name");
+ t.addField("name"); // withField skips duplicates
+ assertEquals(1, t.getNumFields());
+ }
+
+ @Test
+ void addFields()
+ {
+ Table t = new Table();
+ t.addFields(Arrays.asList("a", "b", "c"));
+ assertEquals(3, t.getNumFields());
+ }
+
+ // ===== addRecord =====
+
+ @Test
+ void addRecordFromArray()
+ {
+ Table t = new Table();
+ t.addField("name");
+ t.addField("value");
+ t.addRecord(new String[]{"foo", "42"});
+ assertEquals(1, t.getNumRecords());
+ }
+
+ @Test
+ void addRecordFromRecord()
+ {
+ Table t = new Table();
+ t.addField("name");
+ t.addField("value");
+ Record r = new Record().with("name", "bar").with("value", "99");
+ t.addRecord(r);
+ assertEquals(1, t.getNumRecords());
+ }
+
+ @Test
+ void addMultipleRecords()
+ {
+ Table t = new Table();
+ t.addField("x");
+ t.addRecord(new String[]{"1"});
+ t.addRecord(new String[]{"2"});
+ t.addRecord(new String[]{"3"});
+ assertEquals(3, t.getNumRecords());
+ }
+
+ // ===== get / set =====
+
+ @Test
+ void getByKeyAndField()
+ {
+ Table t = new Table();
+ t.addField("name");
+ t.addField("value");
+ Record r = new Record().with("name", "test").with("value", "123");
+ t.addRecord(r);
+
+ List keys = t.getKeys();
+ assertEquals(1, keys.size());
+ assertEquals("test", t.get(keys.get(0), "name"));
+ assertEquals("123", t.get(keys.get(0), "value"));
+ }
+
+ @Test
+ void setByKeyAndField()
+ {
+ Table t = new Table();
+ t.addField("name");
+ t.set(1, "name", "hello");
+ assertEquals("hello", t.get(1, "name"));
+ }
+
+ @Test
+ void setCreatesFieldIfMissing()
+ {
+ Table t = new Table();
+ t.set(1, "newfield", "val");
+ assertTrue(t.hasField("newfield"));
+ assertEquals("val", t.get(1, "newfield"));
+ }
+
+ // ===== field access =====
+
+ @Test
+ void getFields()
+ {
+ Table t = new Table();
+ t.addField("a");
+ t.addField("b");
+ List fields = t.getFields();
+ assertEquals(2, fields.size());
+ assertEquals("a", fields.get(0));
+ assertEquals("b", fields.get(1));
+ }
+
+ @Test
+ void getFieldName()
+ {
+ Table t = new Table();
+ t.addField("first");
+ t.addField("second");
+ assertEquals("first", t.getFieldName(0));
+ assertEquals("second", t.getFieldName(1));
+ }
+
+ @Test
+ void getFieldIndex()
+ {
+ Table t = new Table();
+ t.addField("a");
+ t.addField("b");
+ assertEquals(0, t.getFieldIndex("a"));
+ assertEquals(1, t.getFieldIndex("b"));
+ }
+
+ // ===== getRecord =====
+
+ @Test
+ void getRecord()
+ {
+ Table t = new Table();
+ t.addField("name");
+ t.addField("value");
+ Record r = new Record().with("name", "foo").with("value", "bar");
+ t.addRecord(r);
+
+ Integer key = t.getKeys().get(0);
+ Record retrieved = t.getRecord(key);
+ assertEquals("foo", retrieved.get("name"));
+ assertEquals("bar", retrieved.get("value"));
+ }
+
+ @Test
+ void getRecords()
+ {
+ Table t = new Table();
+ t.addField("x");
+ t.addRecord(new String[]{"1"});
+ t.addRecord(new String[]{"2"});
+ List records = t.getRecords();
+ assertEquals(2, records.size());
+ }
+
+ // ===== getFieldValues =====
+
+ @Test
+ void getFieldValues()
+ {
+ Table t = new Table();
+ t.addField("name");
+ t.addRecord(new String[]{"alice"});
+ t.addRecord(new String[]{"bob"});
+ t.addRecord(new String[]{"charlie"});
+
+ List values = t.getFieldValues("name");
+ assertEquals(3, values.size());
+ assertTrue(values.contains("alice"));
+ assertTrue(values.contains("bob"));
+ assertTrue(values.contains("charlie"));
+ }
+
+ // ===== remove =====
+
+ @Test
+ void remove()
+ {
+ Table t = new Table();
+ t.addField("x");
+ t.addRecord(new String[]{"1"});
+ t.addRecord(new String[]{"2"});
+ assertEquals(2, t.getNumRecords());
+
+ Integer key = t.getKeys().get(0);
+ t.remove(key);
+ assertEquals(1, t.getNumRecords());
+ }
+
+ // ===== hasRow =====
+
+ @Test
+ void hasRow()
+ {
+ Table t = new Table();
+ t.addField("x");
+ t.addRecord(new String[]{"1"});
+ Integer key = t.getKeys().get(0);
+ assertTrue(t.hasRow(key));
+ assertFalse(t.hasRow(999));
+ }
+
+ // ===== where =====
+
+ @Test
+ void where()
+ {
+ Table t = new Table();
+ t.addField("name");
+ t.addField("color");
+ t.addRecord(new Record().with("name", "a").with("color", "red"));
+ t.addRecord(new Record().with("name", "b").with("color", "blue"));
+ t.addRecord(new Record().with("name", "c").with("color", "red"));
+
+ Record query = new Record().with("color", "red");
+ List results = t.where(query);
+ assertEquals(2, results.size());
+ }
+
+ // ===== withField with defaults =====
+
+ @Test
+ void withFieldDefault()
+ {
+ Table t = new Table();
+ t.addField("name");
+ t.addRecord(new String[]{"alice"});
+
+ // adding a new field with default should expand existing rows
+ t.withField("status", "active");
+ assertEquals(2, t.getNumFields());
+ Integer key = t.getKeys().get(0);
+ assertEquals("active", t.get(key, "status"));
+ }
+
+ // ===== copy =====
+
+ @Test
+ void copy()
+ {
+ Table t = new Table();
+ t.addField("x");
+ t.addRecord(new String[]{"1"});
+ t.addRecord(new String[]{"2"});
+
+ Table c = t.copy();
+ assertEquals(t.getNumRecords(), c.getNumRecords());
+ assertEquals(t.getNumFields(), c.getNumFields());
+ }
+
+ @Test
+ void copyIndependence()
+ {
+ Table t = new Table();
+ t.addField("x");
+ t.addRecord(new String[]{"1"});
+
+ Table c = t.copy();
+ c.addRecord(new String[]{"2"});
+ assertEquals(1, t.getNumRecords());
+ assertEquals(2, c.getNumRecords());
+ }
+
+ // ===== proto =====
+
+ @Test
+ void proto()
+ {
+ Table t = new Table();
+ t.addField("a");
+ t.addField("b");
+ t.addRecord(new String[]{"1", "2"});
+
+ Table p = t.proto();
+ assertEquals(2, p.getNumFields());
+ assertEquals(0, p.getNumRecords());
+ }
+
+ // ===== schema =====
+
+ @Test
+ void getSchema()
+ {
+ Table t = new Table();
+ t.addField("name");
+ t.addField("value");
+ Schema s = t.getSchema();
+ assertEquals(2, s.size());
+ assertTrue(s.hasField("name"));
+ assertTrue(s.locked()); // getSchema returns a locked copy
+ }
+
+ // ===== iterator =====
+
+ @Test
+ void iterator()
+ {
+ Table t = new Table();
+ t.addField("x");
+ t.addRecord(new String[]{"1"});
+ t.addRecord(new String[]{"2"});
+ t.addRecord(new String[]{"3"});
+
+ int count = 0;
+ for (String[] row : t)
+ {
+ count++;
+ }
+ assertEquals(3, count);
+ }
+}
diff --git a/test/qit/data/datasets/VectTest.java b/test/qit/data/datasets/VectTest.java
new file mode 100644
index 0000000..400eed0
--- /dev/null
+++ b/test/qit/data/datasets/VectTest.java
@@ -0,0 +1,357 @@
+package qit.data.datasets;
+
+import org.junit.jupiter.api.Test;
+import static org.junit.jupiter.api.Assertions.*;
+
+class VectTest
+{
+ private static final double EPS = 1e-9;
+
+ // ===== construction =====
+
+ @Test
+ void constructWithSize()
+ {
+ Vect v = new Vect(3);
+ assertEquals(3, v.size());
+ assertEquals(0.0, v.get(0), EPS);
+ assertEquals(0.0, v.get(1), EPS);
+ assertEquals(0.0, v.get(2), EPS);
+ }
+
+ @Test
+ void constructWithArray()
+ {
+ Vect v = new Vect(new double[]{1.0, 2.0, 3.0});
+ assertEquals(3, v.size());
+ assertEquals(1.0, v.get(0), EPS);
+ assertEquals(2.0, v.get(1), EPS);
+ assertEquals(3.0, v.get(2), EPS);
+ }
+
+ // ===== accessors =====
+
+ @Test
+ void setAndGet()
+ {
+ Vect v = new Vect(3);
+ v.set(0, 5.0);
+ v.set(1, 10.0);
+ v.set(2, 15.0);
+ assertEquals(5.0, v.get(0), EPS);
+ assertEquals(10.0, v.get(1), EPS);
+ assertEquals(15.0, v.get(2), EPS);
+ }
+
+ @Test
+ void xyzAccessors()
+ {
+ Vect v = new Vect(new double[]{1.0, 2.0, 3.0});
+ assertEquals(1.0, v.getX(), EPS);
+ assertEquals(2.0, v.getY(), EPS);
+ assertEquals(3.0, v.getZ(), EPS);
+ }
+
+ // ===== arithmetic (return new Vect) =====
+
+ @Test
+ void plusScalar()
+ {
+ Vect v = new Vect(new double[]{1.0, 2.0, 3.0});
+ Vect result = v.plus(10.0);
+ assertEquals(11.0, result.get(0), EPS);
+ assertEquals(12.0, result.get(1), EPS);
+ assertEquals(13.0, result.get(2), EPS);
+ // original unchanged
+ assertEquals(1.0, v.get(0), EPS);
+ }
+
+ @Test
+ void plusVect()
+ {
+ Vect a = new Vect(new double[]{1.0, 2.0, 3.0});
+ Vect b = new Vect(new double[]{4.0, 5.0, 6.0});
+ Vect result = a.plus(b);
+ assertEquals(5.0, result.get(0), EPS);
+ assertEquals(7.0, result.get(1), EPS);
+ assertEquals(9.0, result.get(2), EPS);
+ }
+
+ @Test
+ void minusScalar()
+ {
+ Vect v = new Vect(new double[]{10.0, 20.0, 30.0});
+ Vect result = v.minus(5.0);
+ assertEquals(5.0, result.get(0), EPS);
+ assertEquals(15.0, result.get(1), EPS);
+ assertEquals(25.0, result.get(2), EPS);
+ }
+
+ @Test
+ void minusVect()
+ {
+ Vect a = new Vect(new double[]{5.0, 8.0, 12.0});
+ Vect b = new Vect(new double[]{1.0, 3.0, 7.0});
+ Vect result = a.minus(b);
+ assertEquals(4.0, result.get(0), EPS);
+ assertEquals(5.0, result.get(1), EPS);
+ assertEquals(5.0, result.get(2), EPS);
+ }
+
+ @Test
+ void timesScalar()
+ {
+ Vect v = new Vect(new double[]{1.0, 2.0, 3.0});
+ Vect result = v.times(3.0);
+ assertEquals(3.0, result.get(0), EPS);
+ assertEquals(6.0, result.get(1), EPS);
+ assertEquals(9.0, result.get(2), EPS);
+ }
+
+ @Test
+ void divScalar()
+ {
+ Vect v = new Vect(new double[]{10.0, 20.0, 30.0});
+ Vect result = v.div(5.0);
+ assertEquals(2.0, result.get(0), EPS);
+ assertEquals(4.0, result.get(1), EPS);
+ assertEquals(6.0, result.get(2), EPS);
+ }
+
+ // ===== geometry =====
+
+ @Test
+ void dotProduct()
+ {
+ Vect a = new Vect(new double[]{1.0, 2.0, 3.0});
+ Vect b = new Vect(new double[]{4.0, 5.0, 6.0});
+ assertEquals(32.0, a.dot(b), EPS);
+ }
+
+ @Test
+ void crossProduct()
+ {
+ Vect x = new Vect(new double[]{1.0, 0.0, 0.0});
+ Vect y = new Vect(new double[]{0.0, 1.0, 0.0});
+ Vect z = x.cross(y);
+ assertEquals(0.0, z.get(0), EPS);
+ assertEquals(0.0, z.get(1), EPS);
+ assertEquals(1.0, z.get(2), EPS);
+ }
+
+ @Test
+ void crossProductAntiCommutative()
+ {
+ Vect a = new Vect(new double[]{1.0, 2.0, 3.0});
+ Vect b = new Vect(new double[]{4.0, 5.0, 6.0});
+ Vect ab = a.cross(b);
+ Vect ba = b.cross(a);
+ assertEquals(-ab.get(0), ba.get(0), EPS);
+ assertEquals(-ab.get(1), ba.get(1), EPS);
+ assertEquals(-ab.get(2), ba.get(2), EPS);
+ }
+
+ @Test
+ void norm()
+ {
+ Vect v = new Vect(new double[]{3.0, 4.0});
+ assertEquals(5.0, v.norm(), EPS);
+ }
+
+ @Test
+ void norm2()
+ {
+ Vect v = new Vect(new double[]{3.0, 4.0});
+ assertEquals(25.0, v.norm2(), EPS);
+ }
+
+ @Test
+ void norm1()
+ {
+ Vect v = new Vect(new double[]{-3.0, 4.0, -2.0});
+ assertEquals(9.0, v.norm1(), EPS);
+ }
+
+ @Test
+ void dist()
+ {
+ Vect a = new Vect(new double[]{0.0, 0.0});
+ Vect b = new Vect(new double[]{3.0, 4.0});
+ assertEquals(5.0, a.dist(b), EPS);
+ }
+
+ @Test
+ void normalize()
+ {
+ Vect v = new Vect(new double[]{3.0, 4.0, 0.0});
+ Vect n = v.normalize();
+ assertEquals(1.0, n.norm(), 1e-6);
+ assertEquals(0.6, n.get(0), 1e-6);
+ assertEquals(0.8, n.get(1), 1e-6);
+ }
+
+ @Test
+ void angleDeg()
+ {
+ Vect x = new Vect(new double[]{1.0, 0.0, 0.0});
+ Vect y = new Vect(new double[]{0.0, 1.0, 0.0});
+ assertEquals(90.0, x.angleDeg(y), 1e-6);
+ }
+
+ @Test
+ void angleRad()
+ {
+ Vect a = new Vect(new double[]{1.0, 0.0, 0.0});
+ Vect b = new Vect(new double[]{1.0, 0.0, 0.0});
+ assertEquals(0.0, a.angleRad(b), 1e-6);
+ }
+
+ // ===== statistics =====
+
+ @Test
+ void sum()
+ {
+ Vect v = new Vect(new double[]{1.0, 2.0, 3.0, 4.0});
+ assertEquals(10.0, v.sum(), EPS);
+ }
+
+ @Test
+ void mean()
+ {
+ Vect v = new Vect(new double[]{1.0, 2.0, 3.0, 4.0});
+ assertEquals(2.5, v.mean(), EPS);
+ }
+
+ @Test
+ void min()
+ {
+ Vect v = new Vect(new double[]{3.0, 1.0, 5.0, 2.0});
+ assertEquals(1.0, v.min(), EPS);
+ }
+
+ @Test
+ void max()
+ {
+ Vect v = new Vect(new double[]{3.0, 1.0, 5.0, 2.0});
+ assertEquals(5.0, v.max(), EPS);
+ }
+
+ // ===== element-wise transforms =====
+
+ @Test
+ void abs()
+ {
+ Vect v = new Vect(new double[]{-1.0, 2.0, -3.0});
+ Vect result = v.abs();
+ assertEquals(1.0, result.get(0), EPS);
+ assertEquals(2.0, result.get(1), EPS);
+ assertEquals(3.0, result.get(2), EPS);
+ }
+
+ @Test
+ void sq()
+ {
+ Vect v = new Vect(new double[]{2.0, 3.0, 4.0});
+ Vect result = v.sq();
+ assertEquals(4.0, result.get(0), EPS);
+ assertEquals(9.0, result.get(1), EPS);
+ assertEquals(16.0, result.get(2), EPS);
+ }
+
+ @Test
+ void sqrtVect()
+ {
+ Vect v = new Vect(new double[]{4.0, 9.0, 16.0});
+ Vect result = v.sqrt();
+ assertEquals(2.0, result.get(0), EPS);
+ assertEquals(3.0, result.get(1), EPS);
+ assertEquals(4.0, result.get(2), EPS);
+ }
+
+ @Test
+ void expAndLog()
+ {
+ Vect v = new Vect(new double[]{0.0, 1.0, 2.0});
+ Vect e = v.exp();
+ assertEquals(1.0, e.get(0), EPS);
+ assertEquals(Math.E, e.get(1), 1e-6);
+
+ // log(exp(x)) == x
+ Vect roundtrip = e.log();
+ assertEquals(0.0, roundtrip.get(0), 1e-6);
+ assertEquals(1.0, roundtrip.get(1), 1e-6);
+ assertEquals(2.0, roundtrip.get(2), 1e-6);
+ }
+
+ // ===== sub / cat =====
+
+ @Test
+ void sub()
+ {
+ Vect v = new Vect(new double[]{10.0, 20.0, 30.0, 40.0, 50.0});
+ Vect s = v.sub(1, 4);
+ assertEquals(3, s.size());
+ assertEquals(20.0, s.get(0), EPS);
+ assertEquals(30.0, s.get(1), EPS);
+ assertEquals(40.0, s.get(2), EPS);
+ }
+
+ @Test
+ void cat()
+ {
+ Vect a = new Vect(new double[]{1.0, 2.0});
+ Vect b = new Vect(new double[]{3.0, 4.0});
+ Vect c = a.cat(b);
+ assertEquals(4, c.size());
+ assertEquals(1.0, c.get(0), EPS);
+ assertEquals(2.0, c.get(1), EPS);
+ assertEquals(3.0, c.get(2), EPS);
+ assertEquals(4.0, c.get(3), EPS);
+ }
+
+ // ===== predicates =====
+
+ @Test
+ void finite()
+ {
+ Vect good = new Vect(new double[]{1.0, 2.0, 3.0});
+ assertTrue(good.finite());
+
+ Vect bad = new Vect(new double[]{1.0, Double.NaN, 3.0});
+ assertFalse(bad.finite());
+
+ Vect inf = new Vect(new double[]{1.0, Double.POSITIVE_INFINITY, 3.0});
+ assertFalse(inf.finite());
+ }
+
+ // ===== copy independence =====
+
+ @Test
+ void copy()
+ {
+ Vect v = new Vect(new double[]{1.0, 2.0, 3.0});
+ Vect c = v.copy();
+ assertEquals(v.get(0), c.get(0), EPS);
+ c.set(0, 999.0);
+ assertEquals(1.0, v.get(0), EPS); // original unchanged
+ }
+
+ // ===== prod / cumsum =====
+
+ @Test
+ void prod()
+ {
+ Vect v = new Vect(new double[]{2.0, 3.0, 4.0});
+ assertEquals(24.0, v.prod(), EPS);
+ }
+
+ // ===== zero vector =====
+
+ @Test
+ void zeroVector()
+ {
+ Vect v = new Vect(4);
+ assertEquals(0.0, v.norm(), EPS);
+ assertEquals(0.0, v.sum(), EPS);
+ }
+}
diff --git a/test/qit/math/structs/BoxTest.java b/test/qit/math/structs/BoxTest.java
new file mode 100644
index 0000000..4a112de
--- /dev/null
+++ b/test/qit/math/structs/BoxTest.java
@@ -0,0 +1,250 @@
+package qit.math.structs;
+
+import org.junit.jupiter.api.Test;
+import qit.data.datasets.Vect;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class BoxTest
+{
+ private static final double EPS = 1e-9;
+
+ private Box box3D(double minX, double maxX, double minY, double maxY, double minZ, double maxZ)
+ {
+ return new Box(new Interval[]{
+ new Interval(minX, maxX),
+ new Interval(minY, maxY),
+ new Interval(minZ, maxZ)
+ });
+ }
+
+ // ===== creation =====
+
+ @Test
+ void createFromPoint()
+ {
+ Vect p = new Vect(new double[]{1.0, 2.0, 3.0});
+ Box b = Box.create(p);
+ assertEquals(3, b.dim());
+ assertEquals(1.0, b.getMin().get(0), EPS);
+ assertEquals(1.0, b.getMax().get(0), EPS);
+ assertEquals(2.0, b.getMin().get(1), EPS);
+ assertEquals(3.0, b.getMax().get(2), EPS);
+ }
+
+ @Test
+ void createUnionTwoPoints()
+ {
+ Vect a = new Vect(new double[]{0, 0, 0});
+ Vect b = new Vect(new double[]{1, 2, 3});
+ Box box = Box.createUnion(a, b);
+ assertEquals(0.0, box.getMin().get(0), EPS);
+ assertEquals(1.0, box.getMax().get(0), EPS);
+ assertEquals(0.0, box.getMin().get(1), EPS);
+ assertEquals(2.0, box.getMax().get(1), EPS);
+ assertEquals(3.0, box.getMax().get(2), EPS);
+ }
+
+ @Test
+ void createUnionThreePoints()
+ {
+ Vect a = new Vect(new double[]{0, 5, 0});
+ Vect b = new Vect(new double[]{3, 0, 0});
+ Vect c = new Vect(new double[]{0, 0, 7});
+ Box box = Box.createUnion(a, b, c);
+ assertEquals(0.0, box.getMin().get(0), EPS);
+ assertEquals(3.0, box.getMax().get(0), EPS);
+ assertEquals(0.0, box.getMin().get(1), EPS);
+ assertEquals(5.0, box.getMax().get(1), EPS);
+ assertEquals(7.0, box.getMax().get(2), EPS);
+ }
+
+ @Test
+ void createRadius()
+ {
+ Vect center = new Vect(new double[]{5, 5, 5});
+ Box b = Box.createRadius(center, 2.0);
+ assertEquals(3.0, b.getMin().get(0), EPS);
+ assertEquals(7.0, b.getMax().get(0), EPS);
+ }
+
+ // ===== accessors =====
+
+ @Test
+ void dim()
+ {
+ Box b = box3D(0, 1, 0, 1, 0, 1);
+ assertEquals(3, b.dim());
+ }
+
+ @Test
+ void getCenter()
+ {
+ Box b = box3D(0, 10, 0, 20, 0, 30);
+ Vect center = b.getCenter();
+ assertEquals(5.0, center.get(0), EPS);
+ assertEquals(10.0, center.get(1), EPS);
+ assertEquals(15.0, center.get(2), EPS);
+ }
+
+ @Test
+ void getDiameter()
+ {
+ // unit cube: diameter = sqrt(3)
+ Box b = box3D(0, 1, 0, 1, 0, 1);
+ assertEquals(Math.sqrt(3.0), b.getDiameter(), EPS);
+ }
+
+ @Test
+ void getInterval()
+ {
+ Box b = box3D(1, 5, 2, 8, 3, 9);
+ Interval i = b.getInterval(0);
+ assertEquals(1.0, i.getMin(), EPS);
+ assertEquals(5.0, i.getMax(), EPS);
+ }
+
+ // ===== contains =====
+
+ @Test
+ void containsPoint()
+ {
+ Box b = box3D(0, 10, 0, 10, 0, 10);
+ assertTrue(b.contains(new Vect(new double[]{5, 5, 5})));
+ assertTrue(b.contains(new Vect(new double[]{0, 0, 0}))); // boundary
+ assertTrue(b.contains(new Vect(new double[]{10, 10, 10}))); // boundary
+ assertFalse(b.contains(new Vect(new double[]{11, 5, 5})));
+ assertFalse(b.contains(new Vect(new double[]{5, -1, 5})));
+ }
+
+ @Test
+ void containsBox()
+ {
+ Box outer = box3D(0, 10, 0, 10, 0, 10);
+ Box inner = box3D(2, 8, 2, 8, 2, 8);
+ Box overlapping = box3D(5, 15, 5, 15, 5, 15);
+ assertTrue(outer.contains(inner));
+ assertTrue(outer.contains(outer));
+ assertFalse(outer.contains(overlapping));
+ assertFalse(inner.contains(outer));
+ }
+
+ // ===== intersects =====
+
+ @Test
+ void intersects()
+ {
+ Box a = box3D(0, 5, 0, 5, 0, 5);
+ Box b = box3D(3, 8, 3, 8, 3, 8);
+ Box c = box3D(6, 9, 6, 9, 6, 9);
+ assertTrue(a.intersects(b));
+ assertTrue(b.intersects(a));
+ assertFalse(a.intersects(c));
+ assertFalse(c.intersects(a));
+ }
+
+ @Test
+ void intersectsTouching()
+ {
+ Box a = box3D(0, 5, 0, 5, 0, 5);
+ Box b = box3D(5, 10, 5, 10, 5, 10);
+ assertTrue(a.intersects(b)); // touching at corner
+ }
+
+ // ===== union =====
+
+ @Test
+ void unionBox()
+ {
+ Box a = box3D(0, 5, 0, 5, 0, 5);
+ Box b = box3D(3, 10, 3, 10, 3, 10);
+ Box u = a.union(b);
+ assertEquals(0.0, u.getMin().get(0), EPS);
+ assertEquals(10.0, u.getMax().get(0), EPS);
+ }
+
+ @Test
+ void unionVect()
+ {
+ Box b = box3D(0, 5, 0, 5, 0, 5);
+ Box expanded = b.union(new Vect(new double[]{10, 10, 10}));
+ assertEquals(10.0, expanded.getMax().get(0), EPS);
+ assertEquals(0.0, expanded.getMin().get(0), EPS);
+ }
+
+ // ===== grow / scale / shift =====
+
+ @Test
+ void grow()
+ {
+ Box b = box3D(2, 8, 2, 8, 2, 8);
+ Box grown = b.grow(1.0);
+ assertEquals(1.0, grown.getMin().get(0), EPS);
+ assertEquals(9.0, grown.getMax().get(0), EPS);
+ }
+
+ @Test
+ void scale()
+ {
+ Box b = box3D(0, 10, 0, 10, 0, 10);
+ Box scaled = b.scale(2.0);
+ // size doubles from 10 to 20, so min becomes -5, max becomes 15
+ assertEquals(-5.0, scaled.getMin().get(0), EPS);
+ assertEquals(15.0, scaled.getMax().get(0), EPS);
+ }
+
+ @Test
+ void shift()
+ {
+ Box b = box3D(0, 5, 0, 5, 0, 5);
+ Box shifted = b.shift(new Vect(new double[]{10, 20, 30}));
+ assertEquals(10.0, shifted.getMin().get(0), EPS);
+ assertEquals(15.0, shifted.getMax().get(0), EPS);
+ assertEquals(20.0, shifted.getMin().get(1), EPS);
+ assertEquals(25.0, shifted.getMax().get(1), EPS);
+ }
+
+ @Test
+ void buffer()
+ {
+ Box b = box3D(2, 8, 2, 8, 2, 8);
+ Box buffered = b.buffer(1.0);
+ assertEquals(1.0, buffered.getMin().get(0), EPS);
+ assertEquals(9.0, buffered.getMax().get(0), EPS);
+ }
+
+ // ===== copy =====
+
+ @Test
+ void copy()
+ {
+ Box b = box3D(1, 5, 2, 8, 3, 9);
+ Box c = b.copy();
+ assertEquals(b.getMin().get(0), c.getMin().get(0), EPS);
+ assertEquals(b.getMax().get(2), c.getMax().get(2), EPS);
+ }
+
+ // ===== immutability =====
+
+ @Test
+ void immutability()
+ {
+ Box b = box3D(0, 10, 0, 10, 0, 10);
+ b.grow(5);
+ b.scale(2.0);
+ b.union(new Vect(new double[]{100, 100, 100}));
+ // all methods return new Box, original unchanged
+ assertEquals(0.0, b.getMin().get(0), EPS);
+ assertEquals(10.0, b.getMax().get(0), EPS);
+ }
+
+ // ===== label =====
+
+ @Test
+ void label()
+ {
+ Box b = box3D(0, 10, 0, 10, 0, 10);
+ assertEquals(1, b.label(new Vect(new double[]{5, 5, 5})));
+ assertEquals(0, b.label(new Vect(new double[]{15, 5, 5})));
+ }
+}
diff --git a/test/qit/math/structs/HistogramTest.java b/test/qit/math/structs/HistogramTest.java
new file mode 100644
index 0000000..9b1fe69
--- /dev/null
+++ b/test/qit/math/structs/HistogramTest.java
@@ -0,0 +1,268 @@
+package qit.math.structs;
+
+import org.junit.jupiter.api.Test;
+import qit.data.datasets.Vect;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class HistogramTest
+{
+ private static final double EPS = 1e-9;
+
+ // ===== creation =====
+
+ @Test
+ void createBasic()
+ {
+ Histogram h = Histogram.create(10, 0.0, 1.0);
+ assertEquals(10, h.size());
+ assertEquals(10, h.getBins());
+ }
+
+ @Test
+ void createBreakBounds()
+ {
+ Histogram h = Histogram.create(5, 0.0, 10.0);
+ assertEquals(0.0, h.getBoundBelow(0), EPS);
+ assertEquals(10.0, h.getBoundAbove(h.size() - 1), EPS);
+ }
+
+ // ===== update / contains =====
+
+ @Test
+ void updateAndGet()
+ {
+ Histogram h = Histogram.create(10, 0.0, 10.0);
+ h.update(5.0);
+ h.update(5.0);
+ h.update(5.0);
+ assertEquals(3.0, h.sum(), EPS);
+ }
+
+ @Test
+ void updateOutOfRange()
+ {
+ Histogram h = Histogram.create(10, 0.0, 10.0);
+ h.update(-1.0); // outside range, should be ignored
+ h.update(11.0); // outside range, should be ignored
+ assertEquals(0.0, h.sum(), EPS);
+ }
+
+ @Test
+ void containsValue()
+ {
+ Histogram h = Histogram.create(10, 0.0, 10.0);
+ assertTrue(h.contains(0.0));
+ assertTrue(h.contains(5.0));
+ assertTrue(h.contains(10.0));
+ assertFalse(h.contains(-0.1));
+ assertFalse(h.contains(10.1));
+ }
+
+ // ===== sum =====
+
+ @Test
+ void sum()
+ {
+ Histogram h = Histogram.create(10, 0.0, 10.0);
+ for (int i = 0; i < 100; i++)
+ {
+ h.update(i * 0.1);
+ }
+ assertEquals(100.0, h.sum(), EPS);
+ }
+
+ // ===== maxIndex / maxValue =====
+
+ @Test
+ void maxIndexAndValue()
+ {
+ Histogram h = Histogram.create(10, 0.0, 10.0);
+ // put most samples in one bin
+ h.update(1.0);
+ h.update(1.5);
+ h.update(1.5);
+ h.update(1.5);
+ h.update(5.0);
+
+ assertEquals(h.get(h.maxIndex()), h.maxValue(), EPS);
+ assertTrue(h.maxValue() >= 3.0);
+ }
+
+ // ===== mode =====
+
+ @Test
+ void mode()
+ {
+ Histogram h = Histogram.create(10, 0.0, 10.0);
+ // concentrate values around 5
+ for (int i = 0; i < 50; i++)
+ {
+ h.update(5.0);
+ }
+ h.update(1.0);
+ // mode should be near 5.0
+ double m = h.mode();
+ assertTrue(m > 4.0 && m < 6.0);
+ }
+
+ // ===== normalize =====
+
+ @Test
+ void normalize()
+ {
+ Histogram h = Histogram.create(10, 0.0, 10.0);
+ h.update(1.0);
+ h.update(3.0);
+ h.update(5.0);
+ h.update(7.0);
+ h.update(9.0);
+ h.normalize();
+ assertEquals(1.0, h.sum(), 1e-6);
+ }
+
+ // ===== density =====
+
+ @Test
+ void density()
+ {
+ Histogram h = Histogram.create(10, 0.0, 10.0);
+ h.update(1.0);
+ h.update(3.0);
+ h.update(5.0);
+ Vect d = h.density();
+ assertEquals(h.size(), d.size());
+ assertEquals(1.0, d.sum(), 1e-6);
+ }
+
+ // ===== entropy =====
+
+ @Test
+ void entropyUniform()
+ {
+ // uniform distribution should have high entropy
+ Histogram h = Histogram.create(10, 0.0, 10.0);
+ for (int i = 0; i < 10; i++)
+ {
+ h.update(i + 0.5);
+ }
+ double entropy = h.entropy();
+ assertTrue(entropy > 0);
+ }
+
+ @Test
+ void entropyConcentrated()
+ {
+ // concentrated distribution has lower entropy than uniform
+ Histogram uniform = Histogram.create(10, 0.0, 10.0);
+ for (int i = 0; i < 10; i++)
+ {
+ uniform.update(i + 0.5);
+ }
+
+ Histogram concentrated = Histogram.create(10, 0.0, 10.0);
+ for (int i = 0; i < 10; i++)
+ {
+ concentrated.update(5.0);
+ }
+
+ assertTrue(concentrated.entropy() < uniform.entropy());
+ }
+
+ // ===== otsu =====
+
+ @Test
+ void otsuBimodal()
+ {
+ // bimodal distribution with peaks at low and high ends
+ Histogram h = Histogram.create(100, 0.0, 100.0);
+ // low cluster around 20
+ for (int i = 0; i < 50; i++)
+ {
+ h.update(20.0);
+ }
+ // high cluster around 80
+ for (int i = 0; i < 50; i++)
+ {
+ h.update(80.0);
+ }
+
+ double threshold = h.otsu();
+ // otsu threshold should fall between the two clusters
+ assertTrue(threshold > 20.0 && threshold < 80.0,
+ "Otsu threshold " + threshold + " should be between 20 and 80");
+ }
+
+ // ===== compatible =====
+
+ @Test
+ void compatible()
+ {
+ Histogram a = Histogram.create(10, 0.0, 10.0);
+ Histogram b = Histogram.create(10, 0.0, 10.0);
+ Histogram c = Histogram.create(20, 0.0, 10.0);
+ assertTrue(a.compatible(b));
+ assertFalse(a.compatible(c));
+ }
+
+ // ===== vect =====
+
+ @Test
+ void vect()
+ {
+ Histogram h = Histogram.create(5, 0.0, 5.0);
+ h.update(1.0);
+ h.update(3.0);
+ Vect v = h.vect();
+ assertEquals(5, v.size());
+ assertEquals(2.0, v.sum(), EPS);
+ }
+
+ // ===== copy =====
+
+ @Test
+ void copy()
+ {
+ Histogram h = Histogram.create(10, 0.0, 10.0);
+ h.update(5.0);
+ h.update(5.0);
+ Histogram c = h.copy();
+ assertEquals(h.sum(), c.sum(), EPS);
+ assertEquals(h.size(), c.size());
+
+ // modify copy, original unchanged
+ c.update(5.0);
+ assertEquals(2.0, h.sum(), EPS);
+ assertEquals(3.0, c.sum(), EPS);
+ }
+
+ // ===== table conversion =====
+
+ @Test
+ void table()
+ {
+ Histogram h = Histogram.create(5, 0.0, 5.0);
+ h.update(1.0);
+ qit.data.datasets.Table t = h.table();
+ assertEquals(5, t.getNumRecords());
+ assertTrue(t.hasField("index"));
+ assertTrue(t.hasField("below"));
+ assertTrue(t.hasField("middle"));
+ assertTrue(t.hasField("above"));
+ assertTrue(t.hasField("value"));
+ }
+
+ // ===== smoothBins =====
+
+ @Test
+ void smoothBinsPreservesTotal()
+ {
+ Histogram h = Histogram.create(20, 0.0, 20.0);
+ h.update(10.0);
+ h.update(10.0);
+ h.update(10.0);
+ double sumBefore = h.sum();
+ h.smoothBins(2.0);
+ assertEquals(sumBefore, h.sum(), 1e-4);
+ }
+}
diff --git a/test/qit/math/structs/IntervalTest.java b/test/qit/math/structs/IntervalTest.java
new file mode 100644
index 0000000..b858466
--- /dev/null
+++ b/test/qit/math/structs/IntervalTest.java
@@ -0,0 +1,161 @@
+package qit.math.structs;
+
+import org.junit.jupiter.api.Test;
+import static org.junit.jupiter.api.Assertions.*;
+
+class IntervalTest
+{
+ private static final double EPS = 1e-9;
+
+ @Test
+ void constructBasic()
+ {
+ Interval i = new Interval(1.0, 5.0);
+ assertEquals(1.0, i.getMin(), EPS);
+ assertEquals(5.0, i.getMax(), EPS);
+ }
+
+ @Test
+ void constructReversedThrows()
+ {
+ // constructor asserts min < max || eq(min, max), so reversed args throw
+ assertThrows(RuntimeException.class, () -> new Interval(5.0, 1.0));
+ }
+
+ @Test
+ void constructDegenerate()
+ {
+ Interval i = new Interval(3.0, 3.0);
+ assertEquals(3.0, i.getMin(), EPS);
+ assertEquals(3.0, i.getMax(), EPS);
+ assertEquals(0.0, i.delta(), EPS);
+ }
+
+ @Test
+ void delta()
+ {
+ Interval i = new Interval(2.0, 7.0);
+ assertEquals(5.0, i.delta(), EPS);
+ }
+
+ @Test
+ void size()
+ {
+ Interval i = new Interval(2.0, 7.0);
+ assertEquals(5.0, i.size(), EPS);
+ }
+
+ @Test
+ void getHalf()
+ {
+ Interval i = new Interval(2.0, 8.0);
+ assertEquals(5.0, i.getHalf(), EPS);
+ }
+
+ @Test
+ void containsValue()
+ {
+ Interval i = new Interval(0.0, 10.0);
+ assertTrue(i.contains(5.0));
+ assertTrue(i.contains(0.0)); // boundary
+ assertTrue(i.contains(10.0)); // boundary
+ assertFalse(i.contains(-0.1));
+ assertFalse(i.contains(10.1));
+ }
+
+ @Test
+ void containsInterval()
+ {
+ Interval outer = new Interval(0.0, 10.0);
+ Interval inner = new Interval(2.0, 8.0);
+ Interval overlapping = new Interval(5.0, 15.0);
+
+ assertTrue(outer.contains(inner));
+ assertTrue(outer.contains(outer)); // self-containment
+ assertFalse(outer.contains(overlapping));
+ assertFalse(inner.contains(outer));
+ }
+
+ @Test
+ void intersects()
+ {
+ Interval a = new Interval(0.0, 5.0);
+ Interval b = new Interval(3.0, 8.0);
+ Interval c = new Interval(6.0, 9.0);
+ Interval d = new Interval(5.0, 7.0); // touching at boundary
+
+ assertTrue(a.intersects(b));
+ assertTrue(b.intersects(a));
+ assertFalse(a.intersects(c));
+ assertFalse(c.intersects(a));
+ assertTrue(a.intersects(d)); // touching counts as intersecting
+ }
+
+ @Test
+ void unionInterval()
+ {
+ Interval a = new Interval(1.0, 5.0);
+ Interval b = new Interval(3.0, 8.0);
+ Interval u = a.union(b);
+
+ assertEquals(1.0, u.getMin(), EPS);
+ assertEquals(8.0, u.getMax(), EPS);
+ }
+
+ @Test
+ void unionValue()
+ {
+ Interval i = new Interval(2.0, 6.0);
+
+ Interval expanded = i.union(10.0);
+ assertEquals(2.0, expanded.getMin(), EPS);
+ assertEquals(10.0, expanded.getMax(), EPS);
+
+ Interval shrunkSide = i.union(-1.0);
+ assertEquals(-1.0, shrunkSide.getMin(), EPS);
+ assertEquals(6.0, shrunkSide.getMax(), EPS);
+
+ Interval same = i.union(4.0); // value inside interval
+ assertEquals(2.0, same.getMin(), EPS);
+ assertEquals(6.0, same.getMax(), EPS);
+ }
+
+ @Test
+ void grow()
+ {
+ Interval i = new Interval(2.0, 8.0);
+ Interval grown = i.grow(1.0);
+ assertEquals(1.0, grown.getMin(), EPS);
+ assertEquals(9.0, grown.getMax(), EPS);
+ }
+
+ @Test
+ void shift()
+ {
+ Interval i = new Interval(2.0, 8.0);
+ Interval shifted = i.shift(3.0);
+ assertEquals(5.0, shifted.getMin(), EPS);
+ assertEquals(11.0, shifted.getMax(), EPS);
+ }
+
+ @Test
+ void copy()
+ {
+ Interval i = new Interval(1.0, 5.0);
+ Interval c = i.copy();
+ assertEquals(i.getMin(), c.getMin(), EPS);
+ assertEquals(i.getMax(), c.getMax(), EPS);
+ }
+
+ @Test
+ void immutability()
+ {
+ Interval i = new Interval(2.0, 8.0);
+ i.grow(5.0);
+ i.shift(10.0);
+ i.union(100.0);
+ // original should be unchanged since all methods return new instances
+ assertEquals(2.0, i.getMin(), EPS);
+ assertEquals(8.0, i.getMax(), EPS);
+ }
+}
diff --git a/test/qit/math/structs/QuaternionTest.java b/test/qit/math/structs/QuaternionTest.java
new file mode 100644
index 0000000..b57e932
--- /dev/null
+++ b/test/qit/math/structs/QuaternionTest.java
@@ -0,0 +1,223 @@
+package qit.math.structs;
+
+import org.junit.jupiter.api.Test;
+import qit.data.datasets.Matrix;
+import qit.data.datasets.Vect;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class QuaternionTest
+{
+ private static final double EPS = 1e-6;
+
+ // ===== construction =====
+
+ @Test
+ void constructIdentity()
+ {
+ // b=c=d=0 => identity rotation (a = sqrt(1-0) = 1)
+ Quaternion q = new Quaternion(0, 0, 0);
+ assertEquals(1.0, q.getA(), EPS);
+ assertEquals(0.0, q.getB(), EPS);
+ assertEquals(0.0, q.getC(), EPS);
+ assertEquals(0.0, q.getD(), EPS);
+ }
+
+ @Test
+ void constructFromAxisAngle()
+ {
+ // 0-degree rotation about any axis => identity
+ Vect zAxis = new Vect(new double[]{0, 0, 1});
+ Quaternion q = new Quaternion(zAxis, 0.0);
+ assertEquals(1.0, q.getA(), EPS);
+ assertEquals(0.0, q.getB(), EPS);
+ assertEquals(0.0, q.getC(), EPS);
+ assertEquals(0.0, q.getD(), EPS);
+ }
+
+ @Test
+ void constructFromAxisAngle90Z()
+ {
+ // 90-degree rotation about Z axis
+ Vect zAxis = new Vect(new double[]{0, 0, 1});
+ Quaternion q = new Quaternion(zAxis, Math.PI / 2);
+ assertEquals(Math.cos(Math.PI / 4), q.getA(), EPS);
+ assertEquals(0.0, q.getB(), EPS);
+ assertEquals(0.0, q.getC(), EPS);
+ assertEquals(Math.sin(Math.PI / 4), q.getD(), EPS);
+ }
+
+ @Test
+ void constructFromAxisAngle180()
+ {
+ // 180-degree rotation about X axis
+ Vect xAxis = new Vect(new double[]{1, 0, 0});
+ Quaternion q = new Quaternion(xAxis, Math.PI);
+ assertEquals(0.0, q.getA(), EPS);
+ assertEquals(1.0, Math.abs(q.getB()), EPS); // could be +/- 1
+ }
+
+ // ===== accessors =====
+
+ @Test
+ void getADerived()
+ {
+ // a = sqrt(1 - b^2 - c^2 - d^2) for unit quaternion
+ Quaternion q = new Quaternion(0.5, 0.5, 0.0);
+ double expected = Math.sqrt(1.0 - 0.25 - 0.25);
+ assertEquals(expected, q.getA(), EPS);
+ }
+
+ // ===== inverse =====
+
+ @Test
+ void inverse()
+ {
+ Quaternion q = new Quaternion(0.1, 0.2, 0.3);
+ Quaternion inv = q.inverse();
+ assertEquals(-q.getB(), inv.getB(), EPS);
+ assertEquals(-q.getC(), inv.getC(), EPS);
+ assertEquals(-q.getD(), inv.getD(), EPS);
+ }
+
+ @Test
+ void inverseRotationIsIdentity()
+ {
+ // q * q^-1 applied as rotation should yield identity matrix
+ Vect axis = new Vect(new double[]{1, 1, 1});
+ Quaternion q = new Quaternion(axis, Math.PI / 3);
+ Matrix rot = q.matrix();
+ Matrix invRot = q.inverse().matrix();
+ Matrix product = rot.times(invRot);
+
+ // should be close to identity
+ for (int i = 0; i < 3; i++)
+ {
+ for (int j = 0; j < 3; j++)
+ {
+ double expected = (i == j) ? 1.0 : 0.0;
+ assertEquals(expected, product.get(i, j), EPS);
+ }
+ }
+ }
+
+ // ===== matrix conversion =====
+
+ @Test
+ void identityMatrix()
+ {
+ Quaternion id = new Quaternion(0, 0, 0);
+ Matrix m = id.matrix();
+ assertEquals(3, m.rows());
+ assertEquals(3, m.cols());
+ for (int i = 0; i < 3; i++)
+ {
+ for (int j = 0; j < 3; j++)
+ {
+ double expected = (i == j) ? 1.0 : 0.0;
+ assertEquals(expected, m.get(i, j), EPS);
+ }
+ }
+ }
+
+ @Test
+ void rotationMatrixIsOrthogonal()
+ {
+ // R * R^T = I for any rotation matrix
+ Vect axis = new Vect(new double[]{1, 2, 3});
+ Quaternion q = new Quaternion(axis, 1.23);
+ Matrix r = q.matrix();
+ Matrix product = r.times(r.transpose());
+
+ for (int i = 0; i < 3; i++)
+ {
+ for (int j = 0; j < 3; j++)
+ {
+ double expected = (i == j) ? 1.0 : 0.0;
+ assertEquals(expected, product.get(i, j), EPS);
+ }
+ }
+ }
+
+ @Test
+ void rotationMatrixDetIsOne()
+ {
+ Vect axis = new Vect(new double[]{0, 1, 0});
+ Quaternion q = new Quaternion(axis, Math.PI / 6);
+ Matrix r = q.matrix();
+ assertEquals(1.0, r.det(), EPS);
+ }
+
+ @Test
+ void matrix90ZRotatesXtoY()
+ {
+ // 90-degree rotation about Z: [1,0,0] -> [0,1,0]
+ Vect zAxis = new Vect(new double[]{0, 0, 1});
+ Quaternion q = new Quaternion(zAxis, Math.PI / 2);
+ Matrix r = q.matrix();
+
+ Vect x = new Vect(new double[]{1, 0, 0});
+ Vect result = r.times(x);
+ assertEquals(0.0, result.get(0), EPS);
+ assertEquals(1.0, result.get(1), EPS);
+ assertEquals(0.0, result.get(2), EPS);
+ }
+
+ @Test
+ void matrix4()
+ {
+ Quaternion q = new Quaternion(0, 0, 0);
+ Matrix m4 = q.matrix4();
+ assertEquals(4, m4.rows());
+ assertEquals(4, m4.cols());
+ assertEquals(1.0, m4.get(3, 3), EPS);
+ assertEquals(0.0, m4.get(3, 0), EPS);
+ }
+
+ // ===== roundtrip: quaternion -> matrix -> quaternion =====
+
+ @Test
+ void matrixToQuaternionRoundtrip()
+ {
+ Vect axis = new Vect(new double[]{1, 0, 0});
+ double angle = Math.PI / 4;
+ Quaternion original = new Quaternion(axis, angle);
+ Matrix rot = original.matrix();
+ Quaternion recovered = new Quaternion(rot);
+
+ // rotation matrices should match
+ Matrix rotRecovered = recovered.matrix();
+ for (int i = 0; i < 3; i++)
+ {
+ for (int j = 0; j < 3; j++)
+ {
+ assertEquals(rot.get(i, j), rotRecovered.get(i, j), EPS);
+ }
+ }
+ }
+
+ // ===== equality =====
+
+ @Test
+ void equalsAndHashCode()
+ {
+ Quaternion a = new Quaternion(0.1, 0.2, 0.3);
+ Quaternion b = new Quaternion(0.1, 0.2, 0.3);
+ Quaternion c = new Quaternion(0.4, 0.5, 0.6);
+ assertEquals(a, b);
+ assertNotEquals(a, c);
+ assertEquals(a, a);
+ assertNotEquals(a, "not a quaternion");
+ }
+
+ // ===== copy =====
+
+ @Test
+ void copy()
+ {
+ Quaternion q = new Quaternion(0.1, 0.2, 0.3);
+ Quaternion c = q.copy();
+ assertEquals(q, c);
+ assertEquals(q.getB(), c.getB(), EPS);
+ }
+}
diff --git a/test/qit/math/structs/TriangleTest.java b/test/qit/math/structs/TriangleTest.java
new file mode 100644
index 0000000..09feec7
--- /dev/null
+++ b/test/qit/math/structs/TriangleTest.java
@@ -0,0 +1,178 @@
+package qit.math.structs;
+
+import org.junit.jupiter.api.Test;
+import qit.data.datasets.Vect;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class TriangleTest
+{
+ private static final double EPS = 1e-6;
+
+ private Triangle rightTriangleXY()
+ {
+ Vect a = new Vect(new double[]{0, 0, 0});
+ Vect b = new Vect(new double[]{1, 0, 0});
+ Vect c = new Vect(new double[]{0, 1, 0});
+ return new Triangle(a, b, c);
+ }
+
+ // ===== construction and accessors =====
+
+ @Test
+ void vertices()
+ {
+ Triangle t = rightTriangleXY();
+ assertEquals(0.0, t.getA().get(0), EPS);
+ assertEquals(1.0, t.getB().get(0), EPS);
+ assertEquals(1.0, t.getC().get(1), EPS);
+ }
+
+ @Test
+ void verticesCopyIndependence()
+ {
+ Triangle t = rightTriangleXY();
+ Vect a = t.getA();
+ a.set(0, 999.0);
+ assertEquals(0.0, t.getA().get(0), EPS);
+ }
+
+ // ===== area =====
+
+ @Test
+ void areaRightTriangle()
+ {
+ // right triangle with legs 1,1: area = 0.5
+ Triangle t = rightTriangleXY();
+ assertEquals(0.5, t.area(), EPS);
+ }
+
+ @Test
+ void areaEquilateral()
+ {
+ // equilateral triangle with side 2: area = sqrt(3)
+ Vect a = new Vect(new double[]{0, 0, 0});
+ Vect b = new Vect(new double[]{2, 0, 0});
+ Vect c = new Vect(new double[]{1, Math.sqrt(3), 0});
+ Triangle t = new Triangle(a, b, c);
+ assertEquals(Math.sqrt(3), t.area(), EPS);
+ }
+
+ @Test
+ void areaDegenerateTriangle()
+ {
+ // collinear points => area 0
+ Vect a = new Vect(new double[]{0, 0, 0});
+ Vect b = new Vect(new double[]{1, 0, 0});
+ Vect c = new Vect(new double[]{2, 0, 0});
+ Triangle t = new Triangle(a, b, c);
+ assertEquals(0.0, t.area(), EPS);
+ }
+
+ // ===== center =====
+
+ @Test
+ void center()
+ {
+ Triangle t = rightTriangleXY();
+ Vect center = t.center();
+ assertEquals(1.0 / 3.0, center.get(0), EPS);
+ assertEquals(1.0 / 3.0, center.get(1), EPS);
+ assertEquals(0.0, center.get(2), EPS);
+ }
+
+ // ===== plane / normal =====
+
+ @Test
+ void planeNormal()
+ {
+ // right triangle in XY plane should have normal along Z
+ Triangle t = rightTriangleXY();
+ Plane p = t.plane();
+ // plane equation: ax + by + cz + d = 0
+ // for XY plane: z = 0, so normal is (0,0,1) or (0,0,-1)
+ // a should be ~0, b should be ~0
+ // we verify all three vertices lie on the plane
+ Vect va = t.getA();
+ double eval = p.getA() * va.get(0) + p.getB() * va.get(1) + p.getC() * va.get(2) + p.getD();
+ assertEquals(0.0, eval, EPS);
+ }
+
+ // ===== closest point / distance =====
+
+ @Test
+ void distAtVertex()
+ {
+ Triangle t = rightTriangleXY();
+ Vect p = new Vect(new double[]{0, 0, 0});
+ assertEquals(0.0, t.dist(p), EPS);
+ }
+
+ @Test
+ void distAboveTriangle()
+ {
+ // point directly above centroid at height 1
+ Triangle t = rightTriangleXY();
+ Vect p = new Vect(new double[]{0.2, 0.2, 1.0});
+ assertEquals(1.0, t.dist(p), EPS);
+ }
+
+ @Test
+ void closestPointOnVertex()
+ {
+ Triangle t = rightTriangleXY();
+ Vect p = new Vect(new double[]{-1, -1, 0});
+ Bary bary = t.closest(p);
+ // closest vertex should be A (origin)
+ assertEquals(1.0, bary.getU(), EPS);
+ assertEquals(0.0, bary.getV(), EPS);
+ assertEquals(0.0, bary.getW(), EPS);
+ }
+
+ @Test
+ void closestPointInside()
+ {
+ Triangle t = rightTriangleXY();
+ Vect p = new Vect(new double[]{0.2, 0.2, 0.0});
+ Bary bary = t.closest(p);
+ // point is inside triangle, barycentric coords should sum to 1
+ assertEquals(1.0, bary.getU() + bary.getV() + bary.getW(), EPS);
+ assertTrue(bary.getU() > 0);
+ assertTrue(bary.getV() > 0);
+ assertTrue(bary.getW() > 0);
+ }
+
+ @Test
+ void vectFromBary()
+ {
+ Triangle t = rightTriangleXY();
+ // barycentric (1,0,0) should give vertex A
+ Bary baryA = new Bary(1, 0, 0);
+ Vect va = t.vect(baryA);
+ assertEquals(0.0, va.get(0), EPS);
+ assertEquals(0.0, va.get(1), EPS);
+
+ // barycentric (0,1,0) should give vertex B
+ Bary baryB = new Bary(0, 1, 0);
+ Vect vb = t.vect(baryB);
+ assertEquals(1.0, vb.get(0), EPS);
+ assertEquals(0.0, vb.get(1), EPS);
+
+ // centroid
+ Bary baryCentroid = new Bary(1.0 / 3, 1.0 / 3, 1.0 / 3);
+ Vect vc = t.vect(baryCentroid);
+ assertEquals(1.0 / 3.0, vc.get(0), EPS);
+ assertEquals(1.0 / 3.0, vc.get(1), EPS);
+ }
+
+ // ===== copy =====
+
+ @Test
+ void copy()
+ {
+ Triangle t = rightTriangleXY();
+ Triangle c = t.copy();
+ assertEquals(t.getA().get(0), c.getA().get(0), EPS);
+ assertEquals(t.getB().get(0), c.getB().get(0), EPS);
+ }
+}
diff --git a/test/qit/math/utils/MathUtilsTest.java b/test/qit/math/utils/MathUtilsTest.java
new file mode 100644
index 0000000..1ddb16d
--- /dev/null
+++ b/test/qit/math/utils/MathUtilsTest.java
@@ -0,0 +1,613 @@
+package qit.math.utils;
+
+import org.junit.jupiter.api.Test;
+import qit.base.Global;
+
+import java.util.Random;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class MathUtilsTest
+{
+ private static final double EPS = 1e-9;
+
+ // ===== equality / comparison =====
+
+ @Test
+ void eqWithinDelta()
+ {
+ assertTrue(MathUtils.eq(1.0, 1.0));
+ assertTrue(MathUtils.eq(1.0, 1.0 + 1e-7)); // within default DELTA=1e-6
+ assertFalse(MathUtils.eq(1.0, 1.0 + 1e-5)); // outside DELTA
+ }
+
+ @Test
+ void eqCustomThreshold()
+ {
+ assertTrue(MathUtils.eq(1.0, 1.5, 0.6));
+ assertFalse(MathUtils.eq(1.0, 1.5, 0.4));
+ }
+
+ @Test
+ void zero()
+ {
+ assertTrue(MathUtils.zero(0.0));
+ assertTrue(MathUtils.zero(1e-7));
+ assertFalse(MathUtils.zero(1e-5));
+ }
+
+ @Test
+ void nonzero()
+ {
+ assertFalse(MathUtils.nonzero(0.0));
+ assertTrue(MathUtils.nonzero(1.0));
+ }
+
+ @Test
+ void unit()
+ {
+ assertTrue(MathUtils.unit(1.0));
+ assertTrue(MathUtils.unit(1.0 + 1e-7));
+ assertFalse(MathUtils.unit(0.5));
+ }
+
+ @Test
+ void valid()
+ {
+ assertTrue(MathUtils.valid(0.0));
+ assertTrue(MathUtils.valid(1e308));
+ assertTrue(MathUtils.valid(-1e308));
+ assertFalse(MathUtils.valid(Double.NaN));
+ assertFalse(MathUtils.valid(Double.POSITIVE_INFINITY));
+ assertFalse(MathUtils.valid(Double.NEGATIVE_INFINITY));
+ }
+
+ @Test
+ void openAndClosed()
+ {
+ assertTrue(MathUtils.open(5.0, 0.0, 10.0));
+ assertFalse(MathUtils.open(0.0, 0.0, 10.0)); // boundary excluded
+ assertFalse(MathUtils.open(10.0, 0.0, 10.0));
+
+ assertTrue(MathUtils.closed(5.0, 0.0, 10.0));
+ assertTrue(MathUtils.closed(0.0, 0.0, 10.0)); // boundary included
+ assertTrue(MathUtils.closed(10.0, 0.0, 10.0));
+ assertFalse(MathUtils.closed(-1.0, 0.0, 10.0));
+ }
+
+ // ===== sign / rounding =====
+
+ @Test
+ void sign()
+ {
+ assertEquals(1, MathUtils.sign(5.0));
+ assertEquals(-1, MathUtils.sign(-3.0));
+ assertEquals(1, MathUtils.sign(0.0)); // sign(0) = 1
+ }
+
+ @Test
+ void signzero()
+ {
+ assertEquals(1, MathUtils.signzero(5.0));
+ assertEquals(-1, MathUtils.signzero(-3.0));
+ assertEquals(0, MathUtils.signzero(0.0));
+ }
+
+ @Test
+ void round()
+ {
+ assertEquals(3, MathUtils.round(2.7));
+ assertEquals(2, MathUtils.round(2.3));
+ // round uses ipart(x+0.5), so for negatives: ipart(-2.7+0.5) = ipart(-2.2) = -2
+ assertEquals(-2, MathUtils.round(-2.7));
+ }
+
+ @Test
+ void ipart()
+ {
+ assertEquals(3, MathUtils.ipart(3.7));
+ assertEquals(-3, MathUtils.ipart(-3.7));
+ }
+
+ @Test
+ void fpart()
+ {
+ assertEquals(0.7, MathUtils.fpart(3.7), 1e-6);
+ }
+
+ // ===== pure math functions =====
+
+ @Test
+ void gaussian()
+ {
+ // standard normal at mean should give 1/sqrt(2*pi) ~ 0.3989
+ double atMean = MathUtils.gaussian(0.0, 0.0, 1.0);
+ assertEquals(1.0 / Math.sqrt(2 * Math.PI), atMean, 1e-10);
+
+ // symmetric: f(mean+1) == f(mean-1)
+ double above = MathUtils.gaussian(1.0, 0.0, 1.0);
+ double below = MathUtils.gaussian(-1.0, 0.0, 1.0);
+ assertEquals(above, below, 1e-10);
+
+ // wider std -> lower peak
+ double wide = MathUtils.gaussian(0.0, 0.0, 2.0);
+ assertTrue(wide < atMean);
+ }
+
+ @Test
+ void cubicthresh()
+ {
+ assertEquals(0.0, MathUtils.cubicthresh(-1.0), EPS);
+ assertEquals(0.0, MathUtils.cubicthresh(0.0), EPS);
+ assertEquals(1.0, MathUtils.cubicthresh(1.0), EPS);
+ assertEquals(1.0, MathUtils.cubicthresh(2.0), EPS);
+ assertEquals(0.5, MathUtils.cubicthresh(0.5), EPS); // 3*(0.25) - 2*(0.125) = 0.5
+ }
+
+ @Test
+ void logGamma()
+ {
+ // logGamma(1) = log(0!) = 0
+ assertEquals(0.0, MathUtils.logGamma(1.0), 1e-6);
+ // logGamma(2) = log(1!) = 0
+ assertEquals(0.0, MathUtils.logGamma(2.0), 1e-6);
+ // logGamma(6) = log(5!) = log(120)
+ assertEquals(Math.log(120.0), MathUtils.logGamma(6.0), 1e-6);
+ }
+
+ @Test
+ void factorial()
+ {
+ assertEquals(1.0, MathUtils.factorial(0), EPS);
+ assertEquals(1.0, MathUtils.factorial(1), EPS);
+ assertEquals(2.0, MathUtils.factorial(2), 1e-6);
+ assertEquals(6.0, MathUtils.factorial(3), 1e-6);
+ assertEquals(24.0, MathUtils.factorial(4), 1e-6);
+ assertEquals(120.0, MathUtils.factorial(5), 1e-4);
+ }
+
+ @Test
+ void sinc()
+ {
+ // sinc(0) = 1 by convention
+ assertEquals(1.0, MathUtils.sinc(0.0), EPS);
+ // sinc(pi) = sin(pi)/pi ~ 0
+ assertEquals(0.0, MathUtils.sinc(Math.PI), 1e-10);
+ }
+
+ @Test
+ void logistic()
+ {
+ // logistic with gain=1, offset=0 at value=0 should be 0.5
+ double mid = MathUtils.logistic(1.0, 0.0, 0.0);
+ assertEquals(0.5, mid, 1e-6);
+
+ // large positive -> near 1
+ double high = MathUtils.logistic(1.0, 0.0, 100.0);
+ assertTrue(high > 0.999);
+
+ // large negative -> near 0
+ double low = MathUtils.logistic(1.0, 0.0, -100.0);
+ assertTrue(low < 0.001);
+ }
+
+ @Test
+ void square()
+ {
+ assertEquals(9.0, MathUtils.square(3.0), EPS);
+ assertEquals(9.0, MathUtils.square(-3.0), EPS);
+ assertEquals(0.0, MathUtils.square(0.0), EPS);
+ }
+
+ @Test
+ void ramp()
+ {
+ // ramp maps value from [low,high] to [0,1]
+ assertEquals(0.0, MathUtils.ramp(0.0, 0.0, 10.0), EPS);
+ assertEquals(1.0, MathUtils.ramp(10.0, 0.0, 10.0), EPS);
+ assertEquals(0.5, MathUtils.ramp(5.0, 0.0, 10.0), EPS);
+ }
+
+ @Test
+ void unitmap()
+ {
+ // maps value in [0,1] to [low,high]
+ assertEquals(0.0, MathUtils.unitmap(0.0, 0.0, 10.0), EPS);
+ assertEquals(10.0, MathUtils.unitmap(1.0, 0.0, 10.0), EPS);
+ assertEquals(5.0, MathUtils.unitmap(0.5, 0.0, 10.0), EPS);
+ }
+
+ // ===== number parsing =====
+
+ @Test
+ void number()
+ {
+ assertTrue(MathUtils.number("3.14"));
+ assertTrue(MathUtils.number("-1"));
+ assertTrue(MathUtils.number("0"));
+ assertFalse(MathUtils.number("abc"));
+ assertFalse(MathUtils.number(""));
+ }
+
+ @Test
+ void parse()
+ {
+ assertEquals(3.14, MathUtils.parse("3.14", 0.0), EPS);
+ assertEquals(0.0, MathUtils.parse("abc", 0.0), EPS);
+ }
+
+ // ===== array operations =====
+
+ @Test
+ void sumDoubleArray()
+ {
+ assertEquals(10.0, MathUtils.sum(new double[]{1.0, 2.0, 3.0, 4.0}), EPS);
+ assertEquals(0.0, MathUtils.sum(new double[]{}), EPS);
+ }
+
+ @Test
+ void sumIntArray()
+ {
+ assertEquals(10, MathUtils.sum(new int[]{1, 2, 3, 4}));
+ }
+
+ @Test
+ void mean()
+ {
+ assertEquals(2.5, MathUtils.mean(new double[]{1.0, 2.0, 3.0, 4.0}), EPS);
+ }
+
+ @Test
+ void var()
+ {
+ // variance of {1,2,3,4}: mean=2.5, var = ((1.5^2+0.5^2+0.5^2+1.5^2)/4) = 1.25
+ assertEquals(1.25, MathUtils.var(new double[]{1.0, 2.0, 3.0, 4.0}), 1e-6);
+ }
+
+ @Test
+ void dotProduct()
+ {
+ assertEquals(32.0, MathUtils.dot(new double[]{1, 2, 3}, new double[]{4, 5, 6}), EPS);
+ }
+
+ @Test
+ void norm()
+ {
+ assertEquals(5.0, MathUtils.norm(new double[]{3.0, 4.0}), EPS);
+ assertEquals(0.0, MathUtils.norm(new double[]{0.0, 0.0}), EPS);
+ }
+
+ @Test
+ void linspace()
+ {
+ double[] result = MathUtils.linspace(0.0, 1.0, 5);
+ assertEquals(5, result.length);
+ assertEquals(0.0, result[0], EPS);
+ assertEquals(1.0, result[4], EPS);
+ assertEquals(0.25, result[1], EPS);
+ }
+
+ @Test
+ void linspaceSinglePointThrows()
+ {
+ // linspace requires a < b
+ assertThrows(IllegalArgumentException.class, () -> MathUtils.linspace(5.0, 5.0, 1));
+ }
+
+ @Test
+ void diff()
+ {
+ double[] result = MathUtils.diff(new double[]{1.0, 3.0, 6.0, 10.0});
+ assertArrayEquals(new double[]{2.0, 3.0, 4.0}, result, EPS);
+ }
+
+ @Test
+ void gradient()
+ {
+ // gradient of linear function should be constant
+ double[] data = new double[]{0.0, 1.0, 2.0, 3.0, 4.0};
+ double[] g = MathUtils.gradient(data, 1.0);
+ assertEquals(5, g.length);
+ // central differences for interior points should all be 1.0
+ for (int i = 1; i < g.length - 1; i++)
+ {
+ assertEquals(1.0, g[i], EPS);
+ }
+ }
+
+ @Test
+ void trapezoidArea()
+ {
+ // trapezoid with base=4, parallel sides 3 and 5: area = 4*(3+5)/2 = 16
+ assertEquals(16.0, MathUtils.trapezoidArea(4.0, 3.0, 5.0), EPS);
+ }
+
+ @Test
+ void trapz()
+ {
+ // integral of constant function 2.0 from 0 to 3
+ double[] x = {0.0, 1.0, 2.0, 3.0};
+ double[] y = {2.0, 2.0, 2.0, 2.0};
+ assertEquals(6.0, MathUtils.trapz(x, y), EPS);
+
+ // integral of x from 0 to 1 = 0.5
+ double[] x2 = {0.0, 0.5, 1.0};
+ double[] y2 = {0.0, 0.5, 1.0};
+ assertEquals(0.5, MathUtils.trapz(x2, y2), EPS);
+ }
+
+ @Test
+ void copyDouble()
+ {
+ double[] orig = {1.0, 2.0, 3.0};
+ double[] c = MathUtils.copy(orig);
+ assertArrayEquals(orig, c, EPS);
+ c[0] = 999.0;
+ assertEquals(1.0, orig[0], EPS); // verify independence
+ }
+
+ @Test
+ void copyInt()
+ {
+ int[] orig = {1, 2, 3};
+ int[] c = MathUtils.copy(orig);
+ assertArrayEquals(orig, c);
+ c[0] = 999;
+ assertEquals(1, orig[0]); // verify independence
+ }
+
+ // ===== array arithmetic =====
+
+ @Test
+ void plusArray()
+ {
+ double[] result = MathUtils.plus(new double[]{1.0, 2.0}, new double[]{3.0, 4.0});
+ assertArrayEquals(new double[]{4.0, 6.0}, result, EPS);
+ }
+
+ @Test
+ void minusArray()
+ {
+ double[] result = MathUtils.minus(new double[]{5.0, 8.0}, new double[]{1.0, 3.0});
+ assertArrayEquals(new double[]{4.0, 5.0}, result, EPS);
+ }
+
+ @Test
+ void timesArray()
+ {
+ double[] result = MathUtils.times(new double[]{2.0, 3.0}, new double[]{4.0, 5.0});
+ assertArrayEquals(new double[]{8.0, 15.0}, result, EPS);
+ }
+
+ @Test
+ void timesScalar()
+ {
+ double[] result = MathUtils.times(new double[]{1.0, 2.0, 3.0}, 2.0);
+ assertArrayEquals(new double[]{2.0, 4.0, 6.0}, result, EPS);
+ }
+
+ @Test
+ void plusScalar()
+ {
+ double[] result = MathUtils.plus(new double[]{1.0, 2.0}, 10.0);
+ assertArrayEquals(new double[]{11.0, 12.0}, result, EPS);
+ }
+
+ @Test
+ void sqrtArray()
+ {
+ double[] result = MathUtils.sqrt(new double[]{4.0, 9.0, 16.0});
+ assertArrayEquals(new double[]{2.0, 3.0, 4.0}, result, EPS);
+ }
+
+ @Test
+ void powArray()
+ {
+ double[] result = MathUtils.pow(new double[]{2.0, 3.0}, 2.0);
+ assertArrayEquals(new double[]{4.0, 9.0}, result, EPS);
+ }
+
+ // ===== min/max =====
+
+ @Test
+ void minArray()
+ {
+ assertEquals(1.0, MathUtils.min(new double[]{3.0, 1.0, 5.0, 2.0}), EPS);
+ }
+
+ @Test
+ void maxArray()
+ {
+ assertEquals(5.0, MathUtils.max(new double[]{3.0, 1.0, 5.0, 2.0}), EPS);
+ }
+
+ @Test
+ void minidx()
+ {
+ assertEquals(1, MathUtils.minidx(new double[]{3.0, 1.0, 5.0, 2.0}));
+ }
+
+ @Test
+ void maxidx()
+ {
+ assertEquals(2, MathUtils.maxidx(new double[]{3.0, 1.0, 5.0, 2.0}));
+ }
+
+ @Test
+ void minThreeArgs()
+ {
+ assertEquals(1, MathUtils.min(3, 1, 2));
+ assertEquals(1.0, MathUtils.min(3.0, 1.0, 2.0), EPS);
+ }
+
+ @Test
+ void maxThreeArgs()
+ {
+ assertEquals(3, MathUtils.max(3, 1, 2));
+ assertEquals(3.0, MathUtils.max(3.0, 1.0, 2.0), EPS);
+ }
+
+ // ===== boolean array operations =====
+
+ @Test
+ void threshold()
+ {
+ boolean[] result = MathUtils.threshold(new double[]{1.0, 5.0, 3.0, 7.0}, 4.0);
+ assertFalse(result[0]);
+ assertTrue(result[1]);
+ assertFalse(result[2]);
+ assertTrue(result[3]);
+ }
+
+ @Test
+ void countTrue()
+ {
+ assertEquals(2, MathUtils.counttrue(new boolean[]{true, false, true, false}));
+ assertEquals(0, MathUtils.counttrue(new boolean[]{false, false}));
+ }
+
+ @Test
+ void countFalse()
+ {
+ assertEquals(2, MathUtils.countfalse(new boolean[]{true, false, true, false}));
+ }
+
+ @Test
+ void flip()
+ {
+ boolean[] result = MathUtils.flip(new boolean[]{true, false, true});
+ assertFalse(result[0]);
+ assertTrue(result[1]);
+ assertFalse(result[2]);
+ }
+
+ @Test
+ void and()
+ {
+ boolean[] result = MathUtils.and(
+ new boolean[]{true, true, false, false},
+ new boolean[]{true, false, true, false}
+ );
+ assertTrue(result[0]);
+ assertFalse(result[1]);
+ assertFalse(result[2]);
+ assertFalse(result[3]);
+ }
+
+ @Test
+ void or()
+ {
+ boolean[] result = MathUtils.or(
+ new boolean[]{true, true, false, false},
+ new boolean[]{true, false, true, false}
+ );
+ assertTrue(result[0]);
+ assertTrue(result[1]);
+ assertTrue(result[2]);
+ assertFalse(result[3]);
+ }
+
+ // ===== int array operations =====
+
+ @Test
+ void plusIntArray()
+ {
+ int[] result = MathUtils.plus(new int[]{1, 2, 3}, 10);
+ assertArrayEquals(new int[]{11, 12, 13}, result);
+ }
+
+ // ===== permutation =====
+
+ @Test
+ void permutationDouble()
+ {
+ // permutation returns indices that sort the array ascending
+ int[] perm = MathUtils.permutation(new double[]{30.0, 10.0, 20.0});
+ assertEquals(1, perm[0]); // 10.0 is smallest
+ assertEquals(2, perm[1]); // 20.0 is middle
+ assertEquals(0, perm[2]); // 30.0 is largest
+ }
+
+ @Test
+ void permutationInt()
+ {
+ int[] perm = MathUtils.permutation(new int[]{30, 10, 20});
+ assertEquals(1, perm[0]);
+ assertEquals(2, perm[1]);
+ assertEquals(0, perm[2]);
+ }
+
+ @Test
+ void inversePerm()
+ {
+ int[] perm = {2, 0, 1};
+ int[] inv = MathUtils.inverse(perm);
+ // inv[perm[i]] == i for all i
+ for (int i = 0; i < perm.length; i++)
+ {
+ assertEquals(i, inv[perm[i]]);
+ }
+ }
+
+ // ===== distance =====
+
+ @Test
+ void distance()
+ {
+ assertEquals(5.0, MathUtils.distance(new double[]{0, 0}, new double[]{3, 4}), EPS);
+ assertEquals(0.0, MathUtils.distance(new double[]{1, 2}, new double[]{1, 2}), EPS);
+ }
+
+ // ===== constant/polyWeights =====
+
+ @Test
+ void constantWeights()
+ {
+ double[] w = MathUtils.constantWeights(3);
+ assertEquals(3, w.length);
+ for (double v : w)
+ {
+ assertEquals(1.0 / 3.0, v, EPS);
+ }
+ }
+
+ // ===== 2D array operations =====
+
+ @Test
+ void sum2D()
+ {
+ double[][] m = {{1.0, 2.0}, {3.0, 4.0}};
+ assertEquals(10.0, MathUtils.sum(m), EPS);
+ }
+
+ @Test
+ void copy2D()
+ {
+ double[][] orig = {{1.0, 2.0}, {3.0, 4.0}};
+ double[][] c = MathUtils.copy(orig);
+ assertEquals(orig[0][0], c[0][0], EPS);
+ c[0][0] = 999.0;
+ assertEquals(1.0, orig[0][0], EPS);
+ }
+
+ @Test
+ void frobeniusNorm()
+ {
+ double[][] data = {{1.0, 2.0}, {3.0, 4.0}};
+ // frobenius = sqrt(1+4+9+16) = sqrt(30)
+ assertEquals(Math.sqrt(30.0), MathUtils.frobeniusNorm(data), EPS);
+ }
+
+ @Test
+ void transpose()
+ {
+ double[][] m = {{1.0, 2.0, 3.0}, {4.0, 5.0, 6.0}};
+ double[][] t = MathUtils.transpose(m);
+ assertEquals(3, t.length);
+ assertEquals(2, t[0].length);
+ assertEquals(1.0, t[0][0], EPS);
+ assertEquals(4.0, t[0][1], EPS);
+ assertEquals(2.0, t[1][0], EPS);
+ assertEquals(5.0, t[1][1], EPS);
+ }
+}