diff --git a/.github/workflows/core-wheels.yml b/.github/workflows/core-wheels.yml new file mode 100644 index 00000000..3c39c7b1 --- /dev/null +++ b/.github/workflows/core-wheels.yml @@ -0,0 +1,91 @@ +name: "Native wheels" + +# Build the wheels and the sdist of loop-interpolation-core. +# pypi.yml calls this workflow and then publishes the files. +# A pull request that changes the crate also runs it, so a broken wheel +# build shows before a release. +# +# The crate uses abi3-py39, so one wheel for each platform works with all +# Python versions from 3.9. + +on: + workflow_call: + pull_request: + branches: + - master + paths: + - 'packages/loop_interpolation_core/**' + - '.github/workflows/core-wheels.yml' + workflow_dispatch: + +permissions: + contents: read + +env: + MANIFEST: packages/loop_interpolation_core/Cargo.toml + +jobs: + wheels: + name: Wheel ${{ matrix.platform.name }} + runs-on: ${{ matrix.platform.runner }} + strategy: + fail-fast: false + matrix: + platform: + - { name: linux-x86_64, runner: ubuntu-latest, target: x86_64, smoke: true } + - { name: linux-aarch64, runner: ubuntu-24.04-arm, target: aarch64, smoke: true } + - { name: macos-arm64, runner: macos-latest, target: aarch64, smoke: true } + # Cross-compiled on the arm64 runner, so it is not run here. + - { name: macos-x86_64, runner: macos-latest, target: x86_64, smoke: false } + - { name: windows-x86_64, runner: windows-latest, target: x64, smoke: true } + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + # On Linux the build runs in a manylinux container. + - name: Build the wheel + uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.platform.target }} + manylinux: auto + args: --release --out dist --manifest-path ${{ env.MANIFEST }} + - name: Install the wheel and solve a small problem + if: matrix.platform.smoke + shell: bash + run: | + python -m pip install numpy + python -m pip install --no-index --find-links dist loop-interpolation-core + python - <<'EOF' + import numpy as np + import loop_interpolation_core as core + + problem = core.Problem([0.0, 0.0, 0.0], [8, 8, 8], [1.0, 1.0, 1.0]) + points = np.array([2.0, 2.0, 2.0, 5.0, 5.0, 5.0]) + problem.add_value(points, np.array([0.0, 1.0]), np.ones(2)) + field, report = problem.solve({}) + assert report["converged"], report + assert field.shape == (512,), field.shape + print("solve converged in", report["iterations"], "iterations") + EOF + - uses: actions/upload-artifact@v4 + with: + name: core-wheel-${{ matrix.platform.name }} + path: dist/*.whl + if-no-files-found: error + + sdist: + name: Sdist + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Build the sdist + uses: PyO3/maturin-action@v1 + with: + command: sdist + args: --out dist --manifest-path ${{ env.MANIFEST }} + - uses: actions/upload-artifact@v4 + with: + name: core-sdist + path: dist/*.tar.gz + if-no-files-found: error diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml index 981ce7cc..4ac08271 100644 --- a/.github/workflows/linter.yml +++ b/.github/workflows/linter.yml @@ -46,12 +46,16 @@ jobs: - name: Lint with ruff run: | ruff check ${{ env.PROJECT_FOLDERS }} --fix --exit-zero + # A pull request from a fork gets a read-only GITHUB_TOKEN, so it + # cannot push the style branch. Skip the push and the PR for it. - uses: stefanzweifel/git-auto-commit-action@v5 + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository with: commit_message: "style: style fixes by ruff and autoformatting by black" branch: lint/style-fixes-${{ github.run_id }} create_branch: true - name: Create Pull Request + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository uses: peter-evans/create-pull-request@v6 with: token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/native.yml b/.github/workflows/native.yml new file mode 100644 index 00000000..9a652c01 --- /dev/null +++ b/.github/workflows/native.yml @@ -0,0 +1,218 @@ +name: "Native solver" + +# This workflow checks the Rust crate loop_interpolation_core. +# It runs only when the crate, the Python adapter or this file changes. + +on: + push: + branches: + - master + paths: + - 'packages/loop_interpolation_core/**' + - 'packages/loop_interpolation/src/loop_interpolation/_native.py' + - '.github/workflows/native.yml' + pull_request: + branches: + - master + paths: + - 'packages/loop_interpolation_core/**' + - 'packages/loop_interpolation/src/loop_interpolation/_native.py' + - '.github/workflows/native.yml' + workflow_dispatch: + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + MANIFEST: packages/loop_interpolation_core/Cargo.toml + +jobs: + # Format, lint and test the crate with all feature sets. + rust: + name: Rust checks + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install the Rust toolchain + run: | + rustup toolchain install stable --profile minimal --component rustfmt --component clippy --target wasm32-unknown-unknown + rustup default stable + - uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + packages/loop_interpolation_core/target + key: rust-${{ runner.os }}-${{ hashFiles('packages/loop_interpolation_core/Cargo.lock') }} + restore-keys: rust-${{ runner.os }}- + - name: Format + run: cargo fmt --manifest-path "$MANIFEST" --check + - name: Clippy, no features + run: cargo clippy --manifest-path "$MANIFEST" --all-targets -- -D warnings + - name: Clippy, parallel + run: cargo clippy --manifest-path "$MANIFEST" --all-targets --features parallel -- -D warnings + - name: Clippy, python + run: cargo clippy --manifest-path "$MANIFEST" --all-targets --features python -- -D warnings + - name: Clippy, wasm + run: cargo clippy --manifest-path "$MANIFEST" --all-targets --features wasm --target wasm32-unknown-unknown -- -D warnings + # Use --release. Debug test runs take many minutes. + - name: Test, no features + run: cargo test --manifest-path "$MANIFEST" --release + - name: Test, parallel + run: cargo test --manifest-path "$MANIFEST" --release --features parallel + + # Build the Python wheel and run the Python tests with the native core installed. + wheel: + name: Wheel ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + packages/loop_interpolation_core/target + key: wheel-${{ runner.os }}-${{ hashFiles('packages/loop_interpolation_core/Cargo.lock') }} + restore-keys: wheel-${{ runner.os }}- + # maturin develop needs a virtual environment. + # The Linux and macOS folder is bin. The Windows folder is Scripts. + - name: Install, build and test + shell: bash + run: | + python -m venv .venv + if [ -f .venv/Scripts/activate ]; then source .venv/Scripts/activate; else source .venv/bin/activate; fi + python -m pip install --upgrade pip + python -m pip install maturin numpy scipy pytest + python -m pip install -e packages/loop_common -e packages/loop_interpolation + maturin develop --release -m packages/loop_interpolation_core/Cargo.toml + python -m pytest packages/loop_interpolation/tests -q + + # Build the WebAssembly package, check its size and run the browser tests. + wasm: + name: WebAssembly + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - uses: actions/setup-node@v4 + with: + node-version: '20' + - name: Install the Rust toolchain + run: | + rustup toolchain install stable --profile minimal --target wasm32-unknown-unknown + rustup default stable + - uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + packages/loop_interpolation_core/target + key: wasm-${{ runner.os }}-${{ hashFiles('packages/loop_interpolation_core/Cargo.lock') }} + restore-keys: wasm-${{ runner.os }}- + # The wasm-bindgen CLI version must equal the wasm-bindgen version in Cargo.lock. + - name: Read the wasm-bindgen version + run: | + V=$(grep -A1 '^name = "wasm-bindgen"$' packages/loop_interpolation_core/Cargo.lock | sed -n 's/^version = "\(.*\)"$/\1/p') + echo "Version: $V" + test -n "$V" + echo "WASM_BINDGEN_VERSION=$V" >> "$GITHUB_ENV" + - name: Cache the wasm-bindgen CLI + id: bindgen-cache + uses: actions/cache@v4 + with: + path: ~/.cargo/bin/wasm-bindgen + key: wasm-bindgen-cli-${{ runner.os }}-${{ env.WASM_BINDGEN_VERSION }} + - name: Install the wasm-bindgen CLI + if: steps.bindgen-cache.outputs.cache-hit != 'true' + run: cargo install wasm-bindgen-cli --version "$WASM_BINDGEN_VERSION" --locked + - name: Build the WebAssembly package + run: bash packages/loop_interpolation_core/scripts/build_wasm.sh + - name: Check the size + run: python -I packages/loop_interpolation_core/scripts/check_wasm_size.py + - name: Install the browser test packages + working-directory: packages/loop_interpolation_core/tests_web + run: | + npm ci + npx playwright install --with-deps chromium + - name: Browser tests + working-directory: packages/loop_interpolation_core/tests_web + run: npm test + + # Slow reference runs. Start these by hand only. + reference: + name: Reference problems + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + - name: Install the Rust toolchain + run: | + rustup toolchain install stable --profile minimal --target wasm32-unknown-unknown + rustup default stable + - uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + packages/loop_interpolation_core/target + key: reference-${{ runner.os }}-${{ hashFiles('packages/loop_interpolation_core/Cargo.lock') }} + restore-keys: reference-${{ runner.os }}- + - name: Read the wasm-bindgen version + run: | + V=$(grep -A1 '^name = "wasm-bindgen"$' packages/loop_interpolation_core/Cargo.lock | sed -n 's/^version = "\(.*\)"$/\1/p') + test -n "$V" + echo "WASM_BINDGEN_VERSION=$V" >> "$GITHUB_ENV" + - name: Cache the wasm-bindgen CLI + id: bindgen-cache + uses: actions/cache@v4 + with: + path: ~/.cargo/bin/wasm-bindgen + key: wasm-bindgen-cli-${{ runner.os }}-${{ env.WASM_BINDGEN_VERSION }} + - name: Install the wasm-bindgen CLI + if: steps.bindgen-cache.outputs.cache-hit != 'true' + run: cargo install wasm-bindgen-cli --version "$WASM_BINDGEN_VERSION" --locked + # The test f32_250_cubed_within_budget is too slow for CI. Skip it. + - name: Ignored Rust tests + run: | + set -o pipefail + cargo test --manifest-path "$MANIFEST" --release --test reference_problems -- --ignored --nocapture --test-threads=1 --skip f32_250_cubed_within_budget 2>&1 | tee rust_reference.log + - name: Write the reference field + run: cargo test --manifest-path "$MANIFEST" --release --test reference_problems r1_write_field -- --ignored + - name: Build the WebAssembly package + run: bash packages/loop_interpolation_core/scripts/build_wasm.sh + - name: Browser reference spec + working-directory: packages/loop_interpolation_core/tests_web + env: + RUN_REFERENCE: '1' + run: | + set -o pipefail + npm ci + npx playwright install --with-deps chromium + npm test -- reference 2>&1 | tee "$GITHUB_WORKSPACE/browser_reference.log" + - name: Write the job summary + if: always() + run: | + { + echo "## Rust reference problems" + echo '```' + cat rust_reference.log 2>/dev/null || echo "No output." + echo '```' + echo "## Browser reference spec" + echo '```' + cat browser_reference.log 2>/dev/null || echo "No output." + echo '```' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/pypi.yml b/.github/workflows/pypi.yml index 6c2f3dc9..84c5a167 100644 --- a/.github/workflows/pypi.yml +++ b/.github/workflows/pypi.yml @@ -6,6 +6,33 @@ permissions: contents: read jobs: + # loop-interpolation-core is the Rust core behind the optional extra + # loop-interpolation[native]. It has compiled wheels, so it is built by + # core-wheels.yml and not by the sdist matrix below. No other package + # waits for it: a failed core upload does not stop their release. + build_core: + name: Build loop-interpolation-core + uses: ./.github/workflows/core-wheels.yml + + # Trusted publishing (OIDC): no password. PyPI must list this repository + # and the workflow file pypi.yml as a trusted publisher of the project. + upload_core_to_pypi: + name: Upload loop-interpolation-core to PyPI + needs: ["build_core"] + runs-on: ubuntu-latest + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + pattern: core-* + path: dist + merge-multiple: true + - uses: pypa/gh-action-pypi-publish@release/v1 + with: + skip-existing: true + verbose: true + # loop_common/loop_interpolation/loopstructural_visualisation are # workspace-local deps of LoopStructural (see [tool.uv.sources] in # pyproject.toml) — loop_common/loop_interpolation are runtime deps, diff --git a/.gitignore b/.gitignore index c50ad674..d2b11043 100644 --- a/.gitignore +++ b/.gitignore @@ -143,3 +143,9 @@ sandbox/* # codebase-graph .codebaseGraph/ + +# loop_interpolation_core build output (wasm pkg) and browser test dependencies +packages/loop_interpolation_core/pkg/ +packages/loop_interpolation_core/tests_web/node_modules/ +packages/loop_interpolation_core/tests_web/test-results/ +packages/loop_interpolation_core/tests_web/playwright-report/ diff --git a/.release-please-config.json b/.release-please-config.json index b1fd9629..c80cc4a8 100644 --- a/.release-please-config.json +++ b/.release-please-config.json @@ -8,6 +8,7 @@ "exclude-paths": [ "packages/loop_common", "packages/loop_interpolation", + "packages/loop_interpolation_core", "packages/map2loop", "packages/loopstructural_visualisation" ] @@ -20,6 +21,17 @@ "release-type": "python", "component": "loop-interpolation" }, + "packages/loop_interpolation_core": { + "release-type": "python", + "component": "loop-interpolation-core", + "extra-files": [ + { + "type": "toml", + "path": "Cargo.toml", + "jsonpath": "$.package.version" + } + ] + }, "packages/map2loop": { "release-type": "python", "component": "map2loop" diff --git a/.release-please-manifest.json b/.release-please-manifest.json index f249a6ba..bb87e566 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -2,6 +2,7 @@ ".": "1.9.0", "packages/loop_common": "0.1.0", "packages/loop_interpolation": "0.1.0", + "packages/loop_interpolation_core": "0.1.0", "packages/map2loop": "3.5.1", "packages/loopstructural_visualisation": "0.2.2" } diff --git a/docs/source/user_guide/index.rst b/docs/source/user_guide/index.rst index cccc80a9..c754775b 100644 --- a/docs/source/user_guide/index.rst +++ b/docs/source/user_guide/index.rst @@ -13,6 +13,7 @@ for a list of topics covered in this section. input_data geological_model interpolation_options + native_solver fold_modelling fault_modelling visualisation diff --git a/docs/source/user_guide/native_solver.rst b/docs/source/user_guide/native_solver.rst new file mode 100644 index 00000000..b6cbe752 --- /dev/null +++ b/docs/source/user_guide/native_solver.rst @@ -0,0 +1,106 @@ +.. _native_solver: + +Native Solver +============= +The native solver is a Rust solver. It solves the same discrete system as the Python solvers, but it uses less memory. +Use it when a model has many nodes and the Python solvers run out of memory. + +The native solver uses the package ``loop_interpolation_core``. Install it with the optional extra: + +.. code-block:: bash + + pip install loop-interpolation[native] + +Select the native solver for an interpolator before the interpolator is set up: + +.. code-block:: python + + interpolator = InterpolatorFactory.create_interpolator(..., solver="native") + +The solver can also be set with ``InterpolatorBuilder.use_solver`` or with ``solve_system(solver=...)``. +Set the solver before ``setup``. When the solver is set before setup, the Python interpolator does not build its sparse rows. +This saves Python memory. When the solver is set after setup, the rows are already built. The result is still correct, but the memory saving is lost. +In this case, the solver logs one warning. + +Solver options +-------------- +The native solver accepts these keyword arguments in ``solver_kwargs``: + +.. list-table:: Native solver options + :widths: 25 75 + :header-rows: 1 + + * - Option + - Meaning + * - tol + - Relative residual tolerance. The default is 1e-8. + * - maxiter + - Maximum number of inner conjugate gradient iterations. The default is 5000. + * - precision + - Type of the solver vectors. ``"f64"`` (double, the default) or ``"f32"`` (single). + * - budget_bytes + - Memory budget in bytes. The solver stops with an error when the estimate is above the budget. + * - rho + - ADMM penalty. The default is 1.0. + * - inner_max_iter + - Maximum number of inner iterations of ADMM. The default is 3000. + * - inner_tol + - Tolerance of the inner iterations of ADMM. The default is 1e-6. + * - admm_abs_tol + - Absolute tolerance of the ADMM outer loop. The default is 1e-4. + * - admm_rel_tol + - Relative tolerance of the ADMM outer loop. The default is 1e-3. + * - ridge_factor + - Ridge factor. The solver adds ``ridge_factor**2`` to the diagonal of the normal equations. 0 removes the ridge term. The default is the ``ridge_factor`` of the interpolator (1e-8). + * - add_ridge_regularisation + - ``False`` removes the ridge term for this solve. The default is the setting of the interpolator. + * - warm_start + - ``True`` starts the solver from the last field of the interpolator, not from 0. If the last field is not finite, the solver starts from 0. The default is ``False``. + * - x0 + - Start value, one value for each node, or a function that takes the support and returns one. It has priority over ``warm_start``. + +The value of ``tol`` in ``solver_kwargs`` replaces the ``tol`` argument of the solve. +A start value needs 8 more bytes for each node until the solve starts. The memory estimate includes these bytes. +Use ``warm_start`` when you solve again after a small change, for example a new weight. The solve then needs fewer iterations. +Any other keyword is rejected with a ``NotImplementedError``. The message lists the accepted keywords. + +Unsupported settings +-------------------- +The native solver does not support these settings. When one of them is set, the solver raises ``NotImplementedError``. The message contains the name of the setting. + +* A region mask (``region``). +* ``use_regularisation_weight_scale``. +* Constant-norm iterations (``constant_norm_iterations``) with a true value. +* ``interface_pair_mode`` with the value ``"all"`` or ``"chain"``. Use ``"star"``. +* ``interface_pair_fallback`` with the value ``"chain"``. +* Matrix-free regularisation (``regularisation_matrix_free``). +* Custom operators (``operators``). +* A 2D support. +* A rectilinear grid. +* A rotated support. + +The native solver works on uniform 3D structured grids only. + +Column scaling (``apply_scaling_matrix``) is ignored. The solver writes one information message to the log. +Column scaling changes the conditioning of the matrix. It does not change the solution. + +Threads +------- +The Python wheel of the core uses several threads. The browser build uses one thread. +On a 100 by 100 by 100 grid, the wheel solved a test problem about 3 times faster than the Python matrix solver. +On one thread, the native solver was about 10 percent slower. The result does not depend on the number of threads (differences are in the last bits). + +Memory estimate +--------------- +The core estimates the memory of a solve before it starts. The estimate uses only the counts of the problem. It does not allocate memory. +The estimate has four parts, in bytes: ``vectors``, ``constraints``, ``working`` and ``total``. + +The solver vectors dominate the estimate. For conjugate gradient in double precision, a solve needs about 40 bytes for each node (measured for a 100 by 100 by 100 grid). +This is true when each axis has 7 or more nodes and there is no directional regularisation. Then the solver applies the regularisation with one stencil for each class of node, and it needs no other buffer for each node. +For a smaller grid, or with directional regularisation, the solver uses the older operator, and a solve needs about 63 bytes for each node. +The ADMM solver, for problems with bounds, needs about the same memory. It keeps a few more values for each bound, not for each node. The constraint records need more memory in addition to these values. + +With ``precision="f32"``, the vector part is half of the value for double precision. + +The core module ``loop_interpolation_core`` returns this estimate from the method ``Problem.estimate(settings)``. The ``loop_interpolation`` package does not call this method. +Use the estimate to choose a grid size before a long solve, or to set ``budget_bytes``. diff --git a/packages/loop_interpolation/pyproject.toml b/packages/loop_interpolation/pyproject.toml index dd0bd82f..fec098c3 100644 --- a/packages/loop_interpolation/pyproject.toml +++ b/packages/loop_interpolation/pyproject.toml @@ -27,6 +27,7 @@ dependencies = ["loop-common", "numpy", "scipy", "pydantic"] [project.optional-dependencies] tests = ["pytest"] +native = ["loop-interpolation-core"] [tool.setuptools.packages.find] where = ["src"] diff --git a/packages/loop_interpolation/src/loop_interpolation/_discrete_interpolator.py b/packages/loop_interpolation/src/loop_interpolation/_discrete_interpolator.py index 77899ba5..a51c1a3e 100644 --- a/packages/loop_interpolation/src/loop_interpolation/_discrete_interpolator.py +++ b/packages/loop_interpolation/src/loop_interpolation/_discrete_interpolator.py @@ -327,6 +327,9 @@ def reset(self): """ self.constraints = {} + # setup_interpolator sets these two values again when it ends. + self._native_setup = False + self._setup_constraint_names = None self.c_ = 0 self.regularisation_scale = np.ones(self.dof) logger.info("Resetting interpolation constraints") @@ -1037,12 +1040,18 @@ def _preprocess_main_system( A: sparse.spmatrix, b: np.ndarray, timing: dict, + add_ridge_regularisation: bool | None = None, + ridge_factor: float | None = None, ) -> tuple[sparse.spmatrix, np.ndarray, sparse.spmatrix | None]: + if add_ridge_regularisation is None: + add_ridge_regularisation = self.add_ridge_regulatisation + if ridge_factor is None: + ridge_factor = self.ridge_factor return _solver_pipeline.preprocess_main_system( A=A, b=b, - add_ridge_regularisation=self.add_ridge_regulatisation, - ridge_factor=self.ridge_factor, + add_ridge_regularisation=add_ridge_regularisation, + ridge_factor=ridge_factor, apply_scaling_matrix=self.apply_scaling_matrix, compute_column_scaling_matrix_fn=self.compute_column_scaling_matrix, logger=logger, @@ -1069,8 +1078,11 @@ def _solve_with_cg( tol: float | None, solver_kwargs: dict, timing: dict, + x0: np.ndarray | None = None, ) -> bool: - self.c, ok = _solver_strategy.solve_with_cg(A, b, tol, solver_kwargs, timing, logger) + self.c, ok = _solver_strategy.solve_with_cg( + A, b, tol, solver_kwargs, timing, logger, x0=x0 + ) return ok def _use_fused_cg_regularisation(self, solver_choice) -> bool: @@ -1101,6 +1113,9 @@ def _solve_with_cg_fused_regularisation( tol: float | None, solver_kwargs: dict, timing: dict, + add_ridge_regularisation: bool | None = None, + ridge_factor: float | None = None, + x0: np.ndarray | None = None, ) -> bool: """CG fast path for ``regularisation_matrix_free=True``: assemble the normal-equations system ``(A_data^T A_data + R_reg^T R_reg) x = @@ -1139,6 +1154,10 @@ def _solve_with_cg_fused_regularisation( separate sparse matvecs (``A_data @ x`` then ``A_data.T @ (...)``) every iteration. """ + if add_ridge_regularisation is None: + add_ridge_regularisation = self.add_ridge_regulatisation + if ridge_factor is None: + ridge_factor = self.ridge_factor assembly_started = perf_counter() A_data, b_data = self._assemble_explicit_constraint_matrix() A_data = A_data.tocsr() @@ -1146,8 +1165,8 @@ def _solve_with_cg_fused_regularisation( AtA_data = (A_data.T @ A_data).tocsr() rhs = np.asarray(A_data.T @ b_data).reshape(-1) - if self.add_ridge_regulatisation: - AtA_data = AtA_data + sparse.eye(dof, format="csr") * (self.ridge_factor**2) + if add_ridge_regularisation: + AtA_data = AtA_data + sparse.eye(dof, format="csr") * (ridge_factor**2) reg_operator = self._build_fused_cg_regularisation_operator() timing["assembly_seconds"] = perf_counter() - assembly_started @@ -1170,7 +1189,7 @@ def matvec(x): timing["preprocess_seconds"] = perf_counter() - preprocess_started self.c, ok = _solver_strategy.solve_with_cg_normal_equations( - normal_operator, rhs, tol, solver_kwargs, timing, logger + normal_operator, rhs, tol, solver_kwargs, timing, logger, x0=x0 ) return ok @@ -1181,8 +1200,11 @@ def _solve_with_lsmr( tol: float | None, solver_kwargs: dict, timing: dict, + x0: np.ndarray | None = None, ) -> bool: - self.c, ok = _solver_strategy.solve_with_lsmr(A, b, tol, solver_kwargs, timing, logger) + self.c, ok = _solver_strategy.solve_with_lsmr( + A, b, tol, solver_kwargs, timing, logger, x0=x0 + ) return ok def _extract_admm_kwargs(self, solver_kwargs: dict, admm_solve) -> tuple[str, dict]: @@ -1199,10 +1221,13 @@ def _solve_with_admm( constant_norm_iterations: int, constant_norm_weight: float, constant_norm_target: float | None, + x0: np.ndarray | None = None, + ridge_kwargs: dict | None = None, ) -> bool: logger.info("Solving using admm") - constant_norm_solver_kwargs = dict(solver_kwargs) + # the polish re-solves must use the same ridge options as this solve + constant_norm_solver_kwargs = {**solver_kwargs, **(ridge_kwargs or {})} if Q is None: logger.warning("No inequality constraints, using lsmr") return self.solve_system("lsmr", solver_kwargs=solver_kwargs) @@ -1217,6 +1242,7 @@ def _solve_with_admm( timing, self.support, logger, + x0=x0, ) if not ok: return False @@ -1254,7 +1280,19 @@ def solve_system( solver : string/callable solver 'cg' conjugate gradient, 'lsmr' or callable function solver_kwargs - kwargs for solver check scipy documentation for more information + kwargs for solver check scipy documentation for more information. + These keys are used by the interpolator and not sent to the solver: + + - ``ridge_factor`` (float): ridge factor for this solve. 0 + disables the ridge term. Default ``self.ridge_factor``. + - ``add_ridge_regularisation`` (bool): enable or disable the + ridge term for this solve. Default + ``self.add_ridge_regulatisation``. + - ``warm_start`` (bool): start the solver from the previous + solution. Default False. + - ``x0`` (array or callable): initial guess, or a callable that + takes the support and returns one. Has priority over + ``warm_start``. A custom callable solver ignores it. Returns ------- @@ -1262,12 +1300,63 @@ def solve_system( True if the interpolation is run """ + solver_choice = self._normalise_solver_choice(solver) + # A native setup builds no sparse rows. A matrix solver needs them. + # Check this before _pre_solve, so that the old field stays. + if getattr(self, "_native_setup", False) and solver_choice != "native": + raise ValueError( + "The interpolator was set up with solver='native', so it has no " + "sparse rows. Run setup again with the new solver, or use " + "solver='native'." + ) + # _pre_solve makes a new c array filled with NaN. A native solve that + # does not converge must keep the old field, so keep the old array. + # This is safe only because _pre_solve does not fill c in place. + previous_c = self.c if solver_choice == "native" else None + solver_kwargs = dict(solver_kwargs or {}) + # read the initial guess before _pre_solve clears the previous solution + x0 = _solver_pipeline.extract_initial_guess( + solver_kwargs, self.c, self.support, self.dof, logger + ) + add_ridge, ridge_factor = _solver_pipeline.extract_ridge_options( + solver_kwargs, self.add_ridge_regulatisation, self.ridge_factor, logger + ) + ridge_kwargs = {"add_ridge_regularisation": add_ridge, "ridge_factor": ridge_factor} if not self._pre_solve(): raise ValueError("Pre solve failed") solve_started = perf_counter() - solver_choice = self._normalise_solver_choice(solver) - solver_kwargs = dict(solver_kwargs or {}) + if solver_choice == "native": + self.c = previous_c + # Clear the history and the timing keys of an earlier matrix solve. + self.solver_history = None + self.latest_solve_timing = _solver_pipeline.init_timing("native") + # Import here. The core is not loaded when the package is imported. + from . import _native + + # The ridge and start value keys are already taken out of + # solver_kwargs. The core gets them as arguments. + _native.check_supported(self, getattr(self, "_setup_kwargs", {}), solver_kwargs) + if len(self.constraints) > 0: + # The rows come from a matrix setup. The core ignores them. + logger.warning( + 'solver="native" saves memory only when it is set before setup' + ) + converged = _native.solve( + self, + tol, + solver_kwargs, + x0=x0, + ridge_factor=ridge_factor if add_ridge else 0.0, + ) + self.up_to_date = bool(converged) + # _native.solve sets the timing with a "total" key. Keep it. + timing_native = dict(getattr(self, "latest_solve_timing", None) or {}) + timing_native.setdefault("solver", "native") + timing_native.setdefault("total", perf_counter() - solve_started) + timing_native["up_to_date"] = self.up_to_date + self.latest_solve_timing = timing_native + return self.up_to_date constant_norm_iterations, constant_norm_weight, constant_norm_target = ( _solver_pipeline.extract_constant_norm_options(solver_kwargs, logger) ) @@ -1299,18 +1388,23 @@ def solve_system( # `_solve_with_cg_fused_regularisation` / `FiniteDifferenceInterpolator. # _build_fused_cg_regularisation_operator`. self.up_to_date = self._solve_with_cg_fused_regularisation( - tol, solver_kwargs, timing + tol, solver_kwargs, timing, x0=x0, **ridge_kwargs ) else: A, b = self._assemble_main_system(timing) - A, b, scaling_matrix = self._preprocess_main_system(A, b, timing) + A, b, scaling_matrix = self._preprocess_main_system(A, b, timing, **ridge_kwargs) + if x0 is not None and scaling_matrix is not None: + # the solver works on y where c = S @ y + x0 = x0 / scaling_matrix.diagonal() if callable(solver_choice): self.up_to_date = self._solve_with_callable(solver_choice, A, b, timing) elif solver_choice == "cg": - self.up_to_date = self._solve_with_cg(A, b, tol, solver_kwargs, timing) + self.up_to_date = self._solve_with_cg(A, b, tol, solver_kwargs, timing, x0=x0) elif solver_choice == "lsmr": - self.up_to_date = self._solve_with_lsmr(A, b, tol, solver_kwargs, timing) + self.up_to_date = self._solve_with_lsmr( + A, b, tol, solver_kwargs, timing, x0=x0 + ) elif solver_choice == "admm": self.up_to_date = self._solve_with_admm( A, @@ -1322,6 +1416,8 @@ def solve_system( constant_norm_iterations, constant_norm_weight, constant_norm_target, + x0=x0, + ridge_kwargs=ridge_kwargs, ) else: logger.error(f"Unknown solver {solver_choice}") diff --git a/packages/loop_interpolation/src/loop_interpolation/_finite_difference_interpolator.py b/packages/loop_interpolation/src/loop_interpolation/_finite_difference_interpolator.py index 8ffb92e0..cedad674 100644 --- a/packages/loop_interpolation/src/loop_interpolation/_finite_difference_interpolator.py +++ b/packages/loop_interpolation/src/loop_interpolation/_finite_difference_interpolator.py @@ -179,6 +179,28 @@ def setup_interpolator(self, **kwargs): self.use_regularisation_weight_scale = kwargs.get("use_regularisation_weight_scale", False) self.regularisation_weight_sigma = kwargs.get("regularisation_weight_sigma", None) self.matrix_free_regularisation_blocks = {} + self.interface_pair_mode = kwargs.get("interface_pair_mode", self.interface_pair_mode) + self.interface_pair_threshold = int( + kwargs.get("interface_pair_threshold", self.interface_pair_threshold) + ) + self.interface_pair_fallback = kwargs.get( + "interface_pair_fallback", self.interface_pair_fallback + ) + if self.solver == "native": + # The native solver reads the raw data (get_*_constraints) and the + # stored weights. It does not use sparse rows, so we do not build + # them. This saves Python memory (plan D7.2). + # The weights and kwargs are stored above. This branch is safe to + # run again from update(): reset() already cleared the rows. + self.matrix_free_regularisation_blocks = {} + self.regularisation_matrix_free = False + # add_inequality_pairs_constraints and add_value_inequality_constraints + # only build sparse inequality rows. They store no raw data. The + # core reads the raw inequality data itself, so we skip them here. + self._native_setup = True + self._setup_constraint_names = frozenset(self.constraints) + # The report uses the raw data, not the rows, so it is still valid. + return self.finalize_setup_diagnostics_report() self.regularisation_matrix_free = bool(kwargs.get("regularisation_matrix_free", False)) if self.regularisation_matrix_free and self.apply_scaling_matrix: logger.warning( @@ -201,13 +223,6 @@ def setup_interpolator(self, **kwargs): "consume a LinearOperator and will fall back to explicit regularisation assembly " "(with a warning) for that solve." ) - self.interface_pair_mode = kwargs.get("interface_pair_mode", self.interface_pair_mode) - self.interface_pair_threshold = int( - kwargs.get("interface_pair_threshold", self.interface_pair_threshold) - ) - self.interface_pair_fallback = kwargs.get( - "interface_pair_fallback", self.interface_pair_fallback - ) self.add_norm_constraints(self.interpolation_weights["npw"]) self.add_gradient_constraints(self.interpolation_weights["gpw"]) self.add_value_constraints(self.interpolation_weights["cpw"]) @@ -223,6 +238,8 @@ def setup_interpolator(self, **kwargs): self.assemble_inner(o[0], o[1], name=k) self.add_directional_regularisation(regularisation_config.directional) self.assemble_borders() + # The rows up to here come from setup. Any other row is an extra row. + self._setup_constraint_names = frozenset(self.constraints) return self.finalize_setup_diagnostics_report() def copy(self): diff --git a/packages/loop_interpolation/src/loop_interpolation/_interpolator_builder.py b/packages/loop_interpolation/src/loop_interpolation/_interpolator_builder.py index 0223e27a..55668197 100644 --- a/packages/loop_interpolation/src/loop_interpolation/_interpolator_builder.py +++ b/packages/loop_interpolation/src/loop_interpolation/_interpolator_builder.py @@ -79,6 +79,8 @@ def solve( self, solver: str | None = None, tol: float | None = None, + ridge_factor: float | None = None, + warm_start: bool | None = None, **solver_kwargs, ) -> InterpolatorBuilder: """Solve the configured interpolator system. @@ -89,6 +91,12 @@ def solve( Override solver name for this call. tol : Optional[float], optional Optional solver tolerance. + ridge_factor : Optional[float], optional + Ridge regularisation factor for this call. 0 disables the ridge + term. If None, the interpolator default is used. + warm_start : Optional[bool], optional + If True, start the solver from the previous solution. Use + ``x0`` in ``solver_kwargs`` to give an initial guess directly. **solver_kwargs Additional arguments passed to ``solve_system``. @@ -101,6 +109,10 @@ def solve( selected_solver = solver if solver is not None else self.solver merged_solver_kwargs = dict(self.solver_kwargs) merged_solver_kwargs.update(solver_kwargs) + if ridge_factor is not None: + merged_solver_kwargs["ridge_factor"] = ridge_factor + if warm_start is not None: + merged_solver_kwargs["warm_start"] = warm_start self.interpolator.solve_system( solver=selected_solver, tol=tol, diff --git a/packages/loop_interpolation/src/loop_interpolation/_native.py b/packages/loop_interpolation/src/loop_interpolation/_native.py new file mode 100644 index 00000000..02c77ed8 --- /dev/null +++ b/packages/loop_interpolation/src/loop_interpolation/_native.py @@ -0,0 +1,562 @@ +"""Adapter between a ``FiniteDifferenceInterpolator`` and the native core. + +The native core is the Rust package ``loop_interpolation_core``. This module +reads the stored constraint data of the interpolator, builds a core +``Problem``, solves it, and writes the field back to ``interpolator.c``. + +Two functions are public: + +* ``check_supported(interpolator, setup_kwargs, solver_kwargs)`` raises + ``NotImplementedError`` for a setting that the core cannot represent. + The message contains the name of the setting. +* ``solve(interpolator, tol, solver_kwargs) -> bool`` runs the solve. + +The module does not import the core at import time. The core is imported +inside ``solve``, so this module loads when the core is not installed. + +The adapter reads the raw data with the ``get_*_constraints`` methods. It +never reads the sparse rows. + +MAPPING from the Python data to the core meaning +------------------------------------------------ +The core applies a weight verbatim to a row that it has scaled to unit L2 +norm. These are the equivalent Python weights (the same mapping as +``packages/loop_interpolation_core/fixtures/make_fixture.py``). +Python column layouts are x y z | data | weight. + +* Grid. ``counts`` is ``support.nsteps`` (number of NODES for each axis). + The node order is x fastest, as in ``interpolator.c``. A rotated support + is not supported. +* Regularisation. ``dxx dyy dzz`` are the same in Python and core. The + cross-derivative weights are divided by 4 inside ``get_operators``, so + the core weight is ``dxy / 4`` (same for ``dxz`` and ``dyz``). +* Border. ``dx dy dz`` are the same in Python and core. +* Value. Weight is ``cpw * column 4``. +* Gradient (direction only). Weight is ``gpw``. The weight column is NOT + used by Python. +* Normal (vector with length). Weight is ``npw / 3 * column 6``. When the + data has no weight column, the column weight is 1. +* Tangent. Weight is ``tpw``. The weight column is NOT used by Python. +* Interface. Weight is ``ipw`` (one value for all points). The pairing is + "star": each point is paired with the first point of its id group. +* Value inequality. Columns x y z | lower | upper. The weight is 1. +* Inequality pairs. Columns x y z | rock id. A pair (a, b) gives the bound + ``lower <= f(point of a) - f(point of b) <= upper`` for all point + combinations of the two rock ids. The weight is 1. The first id of a + pair is the upper side. A row with a nan or inf position is dropped. + A pair can have ``n_a * n_b`` rows. The setup keyword + ``inequality_pair_max_rows`` (default 100000) limits the rows of each + pair. When ``n_a * n_b`` is more than the limit, the adapter uses that + many random combinations and writes one warning. The random generator is + ``numpy.random.default_rng`` with the seed ``inequality_pair_seed`` + (default 0), so two runs with the same input give the same rows. The + adapter picks the indices and does not build the full product. It sends + the rows to the core in chunks of 100000. +* Directional regularisation. The direction field is evaluated at the + regularisation sample points (the nodes) and passed with its weight. +* A constraint family with weight 0 is not sent to the core. The core + raises an error for a weight of 0. +* ``ridge_factor`` goes to ``Problem.set_ridge_factor``. ``solve_system`` + gives the value of the solve (``solver_kwargs`` or the interpolator + default). When the ridge is off, the adapter sends 0. +* The start value (``x0`` or ``warm_start`` in ``solver_kwargs``) goes to + ``Problem.set_start``. Without it the core starts from 0. +* Column scaling (``apply_scaling_matrix``) is ignored for any value. It + changes the conditioning of the matrix, not the minimiser. One info line + says that the adapter skips it. +""" + +from __future__ import annotations + +from time import perf_counter + +import numpy as np +from loop_common.logging import get_logger as getLogger + +logger = getLogger(__name__) + +# Keys of solver_kwargs that the adapter accepts. Value: key of the core settings. +_SOLVER_KEYS = { + "tol": "tol", + "maxiter": "max_iter", + "precision": "precision", + "budget_bytes": "budget_bytes", + "rho": "rho", + "inner_max_iter": "inner_max_iter", + "inner_tol": "inner_tol", + "admm_abs_tol": "admm_abs_tol", + "admm_rel_tol": "admm_rel_tol", +} + +# Inequality pairs: default cap of rows for each pair, seed of the sampling, +# and rows for each call of add_pair_bound. +_PAIR_MAX_ROWS = 100_000 +_PAIR_SEED = 0 +_PAIR_CHUNK = 100_000 + +_REGULARISATION_KEYS = ("dxx", "dyy", "dzz", "dxy", "dxz", "dyz") +_BORDER_KEYS = ("dx", "dy", "dz") + +_INSTALL_HINT = ( + "solver='native' needs the package loop_interpolation_core. " + "Install it with: pip install loop-interpolation[native]" +) + + +def _unsupported(setting: str, reason: str = "") -> NotImplementedError: + text = f"solver='native' does not support the setting '{setting}'" + if reason: + text += f": {reason}" + return NotImplementedError(text) + + +def check_supported(interpolator, setup_kwargs, solver_kwargs) -> None: + """Raise ``NotImplementedError`` for a setting that the core cannot represent. + + Parameters + ---------- + interpolator : FiniteDifferenceInterpolator + setup_kwargs : dict or None + The keywords of ``setup_interpolator``. + solver_kwargs : dict or None + The keywords of the solver. + """ + setup_kwargs = dict(setup_kwargs or {}) + solver_kwargs = dict(solver_kwargs or {}) + + # Only the plain finite difference class is known to match the core. + # A subclass must set the class attribute supports_native = True in its + # own body. The flag is not inherited, so a subclass of a supported + # subclass must set it again. + # Fold, P1 and P2 interpolators add terms that the core cannot represent. + from ._finite_difference_interpolator import FiniteDifferenceInterpolator + + cls = type(interpolator) + if cls is not FiniteDifferenceInterpolator and not cls.__dict__.get("supports_native", False): + raise _unsupported( + "interpolator class", + f"{cls.__name__} is not supported; the core works with " + "FiniteDifferenceInterpolator on a uniform structured grid", + ) + + # Sparse rows that the user adds are not in the raw data. The core cannot + # read them. setup_interpolator records the names of the rows that it + # made itself. Each other name in "constraints" is an extra row. This + # is true after a native setup and after a matrix setup. The rows of + # "inequality_feature" are always extra. + if "inequality_feature" in getattr(interpolator, "ineq_constraints", {}): + raise _unsupported( + "add_inequality_feature", "the core reads only the raw data, not sparse rows" + ) + setup_names = getattr(interpolator, "_setup_constraint_names", None) or frozenset() + if set(getattr(interpolator, "constraints", None) or {}) - setup_names: + raise _unsupported( + "add_constraints_to_least_squares", + "the core reads only the raw data, not sparse rows", + ) + + support = interpolator.support + if getattr(support, "dimension", None) != 3: + raise _unsupported("2D support", "the core works on 3D structured grids") + if type(support).__name__ == "RectilinearGrid" or not all( + hasattr(support, name) for name in ("origin", "nsteps", "step_vector") + ): + raise _unsupported("support type", "the core works on uniform structured grids") + rotation = getattr(support, "rotation_xy", None) + if rotation is not None and not np.allclose(np.asarray(rotation, dtype=float), np.eye(3)): + raise _unsupported("rotation_xy", "rotated supports are not supported") + + if setup_kwargs.get("region") is not None: + raise _unsupported("region", "a region mask is not supported") + if "operators" in setup_kwargs: + raise _unsupported("operators", "custom operators are not supported") + if setup_kwargs.get("use_regularisation_weight_scale", False) or getattr( + interpolator, "use_regularisation_weight_scale", False + ): + raise _unsupported("use_regularisation_weight_scale") + if solver_kwargs.get("constant_norm_iterations"): + raise _unsupported("constant_norm_iterations") + if setup_kwargs.get("regularisation_matrix_free", False): + raise _unsupported("regularisation_matrix_free") + + mode = str( + setup_kwargs.get( + "interface_pair_mode", getattr(interpolator, "interface_pair_mode", "auto") + ) + ).lower() + if mode in ("all", "chain"): + raise _unsupported("interface_pair_mode", f"mode '{mode}' is not supported; use 'star'") + if mode == "auto": + fallback = str( + setup_kwargs.get( + "interface_pair_fallback", + getattr(interpolator, "interface_pair_fallback", "star"), + ) + ).lower() + if fallback == "chain": + raise _unsupported("interface_pair_fallback", "the fallback 'chain' is not supported") + + for key in solver_kwargs: + if key not in _SOLVER_KEYS and key != "constant_norm_iterations": + raise NotImplementedError( + f"solver='native' does not support the solver keyword '{key}'. " + f"Supported keywords: {', '.join(_SOLVER_KEYS)}" + ) + + if getattr(interpolator, "apply_scaling_matrix", False): + logger.info("solver='native': column scaling (apply_scaling_matrix) is skipped.") + + +def _core_settings(tol, solver_kwargs) -> dict: + """Make the settings dict of the core from ``tol`` and ``solver_kwargs``.""" + settings = {} + if tol is not None: + settings["tol"] = float(tol) + for key, value in solver_kwargs.items(): + if key == "constant_norm_iterations": + continue + if value is None: + continue + settings[_SOLVER_KEYS[key]] = value + return settings + + +def _flat(a) -> np.ndarray: + return np.ascontiguousarray(np.asarray(a, dtype=np.float64).ravel()) + + +def _weights(w, n: int) -> np.ndarray: + """Return a weight array with ``n`` values from a scalar or an array.""" + return np.ascontiguousarray(np.broadcast_to(np.asarray(w, dtype=np.float64), (n,)).copy()) + + +def _rows(array) -> np.ndarray: + """Return a 2D float array. An empty or missing array gives 0 rows.""" + if array is None: + return np.zeros((0, 0)) + a = np.asarray(array, dtype=np.float64) + if a.ndim != 2 or a.shape[0] == 0: + return np.zeros((0, a.shape[1] if a.ndim == 2 else 0)) + return a + + +def _weight(interpolator, key: str, default: float = 1.0): + return interpolator.interpolation_weights.get(key, default) + + +# How the matrix path treats bad data rows (FD interpolator, shared helpers): +# - A row with a nan or inf in the position or the data is dropped. +# - A negative weight is kept. The solver squares the weight, so only the size counts. +# - A weight of 0 gives a row with no effect. A nan weight is dropped. +# - A gradient or tangent vector of length 0 gives no constraint (dropped). +# - A normal vector of length 0 is kept. It constrains the gradient to 0. +# The native path does the same. A negative weight becomes its absolute value, +# because the core accepts only weights that are more than 0. +def _add_weighted(add, points, data, weights, drop_zero_norm=False, name="data") -> None: + """Call ``add(points, data, weights)`` for the rows that the matrix path keeps.""" + data2 = data.reshape(data.shape[0], -1) + keep = np.isfinite(weights) & (weights != 0) + keep &= np.isfinite(points).all(axis=1) & np.isfinite(data2).all(axis=1) + if drop_zero_norm: + keep &= np.linalg.norm(data2, axis=1) > 0 + n_dropped = int(keep.size - np.count_nonzero(keep)) + if n_dropped: + logger.debug("native: %d of %d %s rows dropped (bad data)", n_dropped, keep.size, name) + if not np.any(keep): + return + add( + _flat(points[keep]), + _flat(data[keep]), + _flat(np.abs(weights[keep])), + ) + + +def _add_data(problem, interpolator) -> None: + """Send the point constraints to the core.""" + # value: weight = cpw * column 4 + value = _rows(interpolator.get_value_constraints()) + if value.shape[0] > 0: + w = _weights(_weight(interpolator, "cpw"), value.shape[0]) * value[:, 4] + _add_weighted(problem.add_value, value[:, :3], value[:, 3], w, name="value") + + # gradient: weight = gpw (the weight column is not used) + gradient = _rows(interpolator.get_gradient_constraints()) + if gradient.shape[0] > 0: + w = _weights(_weight(interpolator, "gpw"), gradient.shape[0]) + _add_weighted( + problem.add_gradient, + gradient[:, :3], + gradient[:, 3:6], + w, + drop_zero_norm=True, + name="gradient", + ) + + # normal: weight = npw / 3 * column 6 (1 when there is no column) + normal = _rows(interpolator.get_norm_constraints()) + if normal.shape[0] > 0: + w = _weights(_weight(interpolator, "npw") / 3.0, normal.shape[0]) + if normal.shape[1] > 6: + w = w * normal[:, 6] + _add_weighted(problem.add_normal, normal[:, :3], normal[:, 3:6], w, name="normal") + + # tangent: weight = tpw (the weight column is not used) + tangent = _rows(interpolator.get_tangent_constraints()) + if tangent.shape[0] > 0: + w = _weights(_weight(interpolator, "tpw"), tangent.shape[0]) + _add_weighted( + problem.add_tangent, + tangent[:, :3], + tangent[:, 3:6], + w, + drop_zero_norm=True, + name="tangent", + ) + + # interface: one weight (ipw) + interface = _rows(interpolator.get_interface_constraints()) + if interface.shape[0] > 1: + ipw = np.asarray(_weight(interpolator, "ipw"), dtype=np.float64) + if ipw.size > 1 and not np.all(ipw == ipw.flat[0]): + raise _unsupported( + "interface per-point weights", "the core takes one weight for all interface points" + ) + ipw = float(ipw.flat[0]) + # Same rules as the matrix path: + # - A row with a nan id or a nan or inf position is dropped. + # - A group with fewer than two points gives no pair and is dropped. + # - A negative weight becomes its absolute value (see _add_weighted). + # - A weight of 0 or a nan weight gives no constraint. + valid = np.isfinite(interface[:, :4]).all(axis=1) + ids, counts = np.unique(interface[valid, 3], return_counts=True) + valid &= np.isin(interface[:, 3], ids[counts > 1]) + n_dropped = int(valid.size - np.count_nonzero(valid)) + if n_dropped: + logger.debug( + "native: %d of %d interface rows dropped (bad id, bad position or single point)", + n_dropped, + valid.size, + ) + if np.isfinite(ipw) and ipw != 0 and np.any(valid): + problem.add_interface(_flat(interface[valid, :3]), _flat(interface[valid, 3]), abs(ipw)) + + +def _add_inequalities(problem, interpolator, setup_kwargs) -> None: + """Send the value bounds and the pair bounds to the core.""" + bounds = _rows(interpolator.get_inequality_value_constraints()) + if bounds.shape[0] > 0: + problem.add_value_bound( + _flat(bounds[:, :3]), + _flat(bounds[:, 3]), + _flat(bounds[:, 4]), + _flat(np.ones(bounds.shape[0])), + ) + + pairs_data = _rows(interpolator.get_inequality_pairs_constraints()) + if pairs_data.shape[0] == 0: + return + upper_bound = setup_kwargs.get("inequality_pair_upper_bound", np.finfo(float).eps) + lower_bound = setup_kwargs.get("inequality_pair_lower_bound", -np.inf) + pairs = setup_kwargs.get("inequality_pairs", None) + max_rows = setup_kwargs.get("inequality_pair_max_rows", _PAIR_MAX_ROWS) + if int(max_rows) < 1: + raise ValueError(f"inequality_pair_max_rows must be 1 or more, got {max_rows}") + max_rows = int(max_rows) + rng = np.random.default_rng(setup_kwargs.get("inequality_pair_seed", _PAIR_SEED)) + # The core stops on a nan or inf position. Drop these rows. + finite = np.isfinite(pairs_data[:, :3]).all(axis=1) + if not finite.all(): + logger.debug("native: %d inequality pair rows dropped (bad position)", int((~finite).sum())) + pairs_data = pairs_data[finite] + ids = pairs_data[:, 3] + if not pairs: + # Same default as the matrix solver: each unordered pair of rock ids. + unique = np.unique(ids) + pairs = [(a, b) for i, a in enumerate(unique) for b in unique[i + 1 :]] + for upper_id, lower_id in pairs: + points_a = pairs_data[ids == upper_id, :3] + points_b = pairs_data[ids == lower_id, :3] + n_a, n_b = points_a.shape[0], points_b.shape[0] + if n_a == 0 or n_b == 0: + continue + # One row for each combination of a point of each rock id. A pair with + # more than max_rows combinations gets a random sample of max_rows rows. + total = n_a * n_b + if total > max_rows: + logger.warning( + "native: inequality pair (%s, %s) has %d point combinations. " + "Only %d random combinations are used (inequality_pair_max_rows).", + upper_id, + lower_id, + total, + max_rows, + ) + flat = _sample_indices(rng, total, max_rows) + else: + flat = np.arange(total) + # Send the rows in chunks. Index arithmetic makes the chunk, so the + # full set of combinations is never in memory. + for start in range(0, flat.size, _PAIR_CHUNK): + k = flat[start : start + _PAIR_CHUNK] + n = k.size + problem.add_pair_bound( + _flat(points_a[k // n_b]), + _flat(points_b[k % n_b]), + _flat(np.full(n, lower_bound)), + _flat(np.full(n, upper_bound)), + _flat(np.ones(n)), + ) + + +def _sample_indices(rng, total: int, count: int) -> np.ndarray: + """Return ``count`` different random integers in ``[0, total)``, sorted. + + ``rng.choice(total, replace=False)`` can allocate ``total`` values, and + ``total`` can be very large. This function allocates about ``count`` + values. When ``total`` is more than 4 times ``count``, it draws with + replacement and removes the repeats until ``count`` values remain. + Then, a repeat is rare and a round or two is enough. + """ + if total <= 4 * count: + return np.sort(rng.choice(total, size=count, replace=False)) + picked = np.unique(rng.integers(0, total, size=count)) + while picked.size < count: + extra = rng.integers(0, total, size=count - picked.size) + picked = np.unique(np.concatenate([picked, extra])) + return picked + + +def _add_regularisation(problem, interpolator, setup_kwargs) -> None: + """Send the regularisation, border and directional weights to the core.""" + reg = {} + for key in _REGULARISATION_KEYS: + value = float(_weight(interpolator, key)) + reg[key] = value / 4.0 if key in ("dxy", "dxz", "dyz") else value + problem.set_regularisation(reg) + problem.set_border_regularisation( + {key: float(_weight(interpolator, key)) for key in _BORDER_KEYS} + ) + + config = interpolator.resolve_regularisation_config( + regularisation=setup_kwargs.get("regularisation", None), + directional_regularisation=setup_kwargs.get("directional_regularisation", None), + ) + if len(config.directional) == 0: + return + sample_points = np.asarray(interpolator.get_regularisation_sample_points(), dtype=float) + for term in config.directional: + if term.weight == 0: + continue + vectors = term.direction(sample_points) if callable(term.direction) else term.direction + vectors = np.asarray(vectors, dtype=float) + if vectors.shape != sample_points.shape: + logger.warning( + "%s: directional regularisation vectors must have shape %s, got %s. Skipping.", + term.name, + sample_points.shape, + vectors.shape, + ) + continue + problem.add_directional_regularisation(_flat(vectors), float(term.weight)) + + +def solve(interpolator, tol, solver_kwargs, x0=None, ridge_factor=None) -> bool: + """Solve the interpolation problem with the native core. + + Parameters + ---------- + interpolator : FiniteDifferenceInterpolator + tol : float or None + Relative residual tolerance. ``None`` keeps the default of the core. + solver_kwargs : dict or None + Keys: tol, maxiter, precision, budget_bytes, rho, inner_max_iter, + inner_tol, admm_abs_tol, admm_rel_tol. ``solve_system`` takes out the + keys ridge_factor, add_ridge_regularisation, warm_start and x0 and + gives them as ``x0`` and ``ridge_factor``. + x0 : np.ndarray or None + Start value, one value for each node. ``None`` starts from 0. + ridge_factor : float or None + Ridge factor. 0 removes the ridge term. ``None`` keeps the default + of the core (1e-8). + + Returns + ------- + bool + True when the solve converged. + + Notes + ----- + ``interpolator.c`` changes only when the solve converged. When the solve + does not converge, ``c`` keeps its old value. The partial field is in + ``interpolator.latest_native_report["field"]``. A converged report has no + ``field`` key. The matrix solvers (for example ``cg``) differ here. They + write the last iterate to ``c`` also when they do not converge. + + The defaults of the core are ``tol=1e-8`` and ``maxiter=5000``. The + ``cg`` matrix solver uses ``rtol=1e-8``. Its ``maxiter`` is the scipy + default, 10 times the number of nodes. So the native solver can stop + earlier on large grids. Set ``maxiter`` to get the same limit. + """ + started = perf_counter() + solver_kwargs = dict(solver_kwargs or {}) + setup_kwargs = dict(getattr(interpolator, "_setup_kwargs", None) or {}) + + # Check the settings first. These errors must not need the core. + check_supported(interpolator, setup_kwargs, solver_kwargs) + + try: + import loop_interpolation_core as core + except ImportError as err: + raise ImportError(_INSTALL_HINT) from err + + support = interpolator.support + problem = core.Problem( + np.asarray(support.origin, dtype=np.float64), + np.asarray(support.nsteps, dtype=np.int64), + np.asarray(support.step_vector, dtype=np.float64), + ) + _add_regularisation(problem, interpolator, setup_kwargs) + _add_data(problem, interpolator) + _add_inequalities(problem, interpolator, setup_kwargs) + if ridge_factor is not None: + problem.set_ridge_factor(float(ridge_factor)) + if x0 is not None: + problem.set_start(np.ascontiguousarray(x0, dtype=np.float64)) + + # tol: the solver_kwargs value wins over the tol argument. + settings = _core_settings(solver_kwargs.get("tol", tol), solver_kwargs) + field, report = problem.solve(settings) + + # The core can return float32. ``c`` is always float64. + field = np.asarray(field, dtype=np.float64) + if field.shape[0] != support.n_nodes: + raise RuntimeError( + f"The native solver returned {field.shape[0]} values. " + f"The support has {support.n_nodes} nodes." + ) + + converged = bool(report.get("converged", False)) + if converged: + interpolator.c = field + else: + # Keep the old ``c``. Give the partial field in the report. + report["field"] = field + interpolator.latest_native_report = report + if not converged: + logger.warning( + "The native solver did not converge: %s iterations, residual %s.", + report.get("iterations"), + report.get("residual"), + ) + for message in report.get("warnings", []): + logger.warning("native solver: %s", message) + + total = perf_counter() - started + interpolator.latest_solve_timing = { + "solver": "native", + "total": total, + "total_seconds": total, + "iterations": report.get("iterations"), + "up_to_date": converged, + } + return converged diff --git a/packages/loop_interpolation/src/loop_interpolation/_solver_pipeline.py b/packages/loop_interpolation/src/loop_interpolation/_solver_pipeline.py index 39632580..d1683fd0 100644 --- a/packages/loop_interpolation/src/loop_interpolation/_solver_pipeline.py +++ b/packages/loop_interpolation/src/loop_interpolation/_solver_pipeline.py @@ -35,6 +35,76 @@ def extract_constant_norm_options(solver_kwargs: dict, logger): return constant_norm_iterations, constant_norm_weight, constant_norm_target +def extract_ridge_options( + solver_kwargs: dict, add_ridge_default: bool, ridge_factor_default: float, logger +): + """Pop per-solve ridge options from solver kwargs. + + ``ridge_factor`` scales the identity rows appended to the system, so it + adds ``ridge_factor**2`` to the diagonal of the normal equations. A + ``ridge_factor`` of 0 disables the ridge term. + + Returns + ------- + tuple[bool, float] + whether to add the ridge term, and the ridge factor + """ + add_ridge = solver_kwargs.pop("add_ridge_regularisation", None) + ridge_factor = solver_kwargs.pop("ridge_factor", None) + if ridge_factor is None: + ridge_factor = float(ridge_factor_default) + if add_ridge is None: + add_ridge = bool(add_ridge_default) + else: + ridge_factor = float(ridge_factor) + if ridge_factor < 0.0: + logger.warning("ridge_factor must be >= 0; using %s", abs(ridge_factor)) + ridge_factor = abs(ridge_factor) + if add_ridge is None: + add_ridge = ridge_factor > 0.0 + return bool(add_ridge) and ridge_factor > 0.0, ridge_factor + + +def extract_initial_guess(solver_kwargs: dict, previous_c, support, dof: int, logger): + """Pop ``x0`` and ``warm_start`` from solver kwargs and resolve the + initial guess for the solver. + + ``x0`` is an array of length ``dof`` or a callable that takes the support + and returns one. It has priority over ``warm_start``. With + ``warm_start=True`` the previous solution is the initial guess, if it is + finite and has the correct length. + + Returns + ------- + Optional[np.ndarray] + initial guess, or None for a zero start + """ + x0 = solver_kwargs.pop("x0", None) + warm_start = bool(solver_kwargs.pop("warm_start", False)) + if x0 is not None: + if callable(x0): + x0 = x0(support) + x0 = np.array(x0, dtype=float).reshape(-1) + if x0.shape[0] != dof: + logger.warning( + "x0 has %d values but the system has %d degrees of freedom; ignoring x0", + x0.shape[0], + dof, + ) + return None + return x0 + if not warm_start: + return None + if previous_c is None: + return None + previous_c = np.array(previous_c, dtype=float).reshape(-1) + if previous_c.shape[0] != dof or not np.all(np.isfinite(previous_c)): + logger.info("No usable previous solution for warm start; starting from zero") + return None + logger.info("Warm starting solver from previous solution") + return previous_c + + def init_timing(solver_choice) -> dict: return { "solver": "callable" if callable(solver_choice) else solver_choice, diff --git a/packages/loop_interpolation/src/loop_interpolation/_solver_strategy.py b/packages/loop_interpolation/src/loop_interpolation/_solver_strategy.py index 6f611d11..a9f447f1 100644 --- a/packages/loop_interpolation/src/loop_interpolation/_solver_strategy.py +++ b/packages/loop_interpolation/src/loop_interpolation/_solver_strategy.py @@ -21,7 +21,7 @@ def resolve_solver_choice( if callable(solver): return solver if isinstance(solver, str) or solver is None: - if solver not in ["cg", "lsmr", "admm", None]: + if solver not in ["cg", "lsmr", "admm", "native", None]: logger.warning( "Unknown solver %s using cg. Available solvers are cg, lsmr, admm or a custom callable", solver, @@ -55,6 +55,7 @@ def solve_with_cg( solver_kwargs: dict, timing: dict, logger, + x0: np.ndarray | None = None, ) -> tuple[np.ndarray, bool]: from time import perf_counter @@ -66,7 +67,7 @@ def solve_with_cg( logger.info(f"Solver kwargs: {solver_kwargs}") solve_step_started = perf_counter() - res = sparse.linalg.cg(A.T @ A, A.T @ b, **solver_kwargs) + res = sparse.linalg.cg(A.T @ A, A.T @ b, x0=x0, **solver_kwargs) timing["solve_seconds"] = perf_counter() - solve_step_started if res[1] > 0: logger.warning( @@ -83,6 +84,7 @@ def solve_with_cg_normal_equations( solver_kwargs: dict, timing: dict, logger, + x0: np.ndarray | None = None, ) -> tuple[np.ndarray, bool]: """Solve an already-assembled normal-equations system ``N x = rhs`` with CG. @@ -102,7 +104,7 @@ def solve_with_cg_normal_equations( logger.info(f"Solver kwargs: {solver_kwargs}") solve_step_started = perf_counter() - res = sparse.linalg.cg(N, rhs, **solver_kwargs) + res = sparse.linalg.cg(N, rhs, x0=x0, **solver_kwargs) timing["solve_seconds"] = perf_counter() - solve_step_started if res[1] > 0: logger.warning( @@ -119,6 +121,7 @@ def solve_with_lsmr( solver_kwargs: dict, timing: dict, logger, + x0: np.ndarray | None = None, ) -> tuple[np.ndarray, bool]: from time import perf_counter @@ -127,6 +130,8 @@ def solve_with_lsmr( solver_kwargs["btol"] = tol solver_kwargs["atol"] = 0.0 logger.info(f"Setting lsmr btol to {tol}") + if x0 is not None: + solver_kwargs["x0"] = x0 logger.info(f"Solver kwargs: {solver_kwargs}") solve_step_started = perf_counter() res = sparse.linalg.lsmr(A, b, **solver_kwargs) @@ -193,16 +198,18 @@ def solve_with_admm( timing: dict, support, logger, + x0: np.ndarray | None = None, ) -> tuple[np.ndarray, list | None, bool]: from time import perf_counter from .loopsolver import admm_solve - if "x0" in solver_kwargs: - x0 = solver_kwargs["x0"](support) - else: + legacy_x0 = solver_kwargs.pop("x0", None) + if x0 is None and legacy_x0 is not None: + x0 = legacy_x0(support) if callable(legacy_x0) else legacy_x0 + if x0 is None: x0 = np.zeros(A.shape[1]) - solver_kwargs.pop("x0", None) + x0 = np.array(x0, dtype=float) linsys_solver, admm_kwargs = extract_admm_kwargs(solver_kwargs, admm_solve) solve_step_started = perf_counter() diff --git a/packages/loop_interpolation/tests/golden/test_golden.py b/packages/loop_interpolation/tests/golden/test_golden.py index dd2012dc..64dd18c9 100644 --- a/packages/loop_interpolation/tests/golden/test_golden.py +++ b/packages/loop_interpolation/tests/golden/test_golden.py @@ -126,7 +126,10 @@ def test_matches_golden_field(case, engine, request): golden, _ = harness.load_golden(case, engine) if golden is None: pytest.fail("no golden file: run with --update-golden and review the figures first") - ok = np.allclose(r["value"], golden["value"], atol=1e-6, rtol=0, equal_nan=True) and np.allclose( + # The solver stops at a relative residual of 1e-8. Other platforms (macOS + # arm64) add in a different order and differ by about 1e-6, so 1e-6 is + # too tight. A real change of the field is much larger than 1e-5. + ok = np.allclose(r["value"], golden["value"], atol=1e-5, rtol=0, equal_nan=True) and np.allclose( r["gradient"], golden["gradient"], atol=1e-5, rtol=0, equal_nan=True ) if not ok: diff --git a/packages/loop_interpolation/tests/test_native_errors.py b/packages/loop_interpolation/tests/test_native_errors.py new file mode 100644 index 00000000..2069b294 --- /dev/null +++ b/packages/loop_interpolation/tests/test_native_errors.py @@ -0,0 +1,254 @@ +"""Tests for the errors of ``solver="native"`` (spec 001, task T029). + +Two rules are checked here: + +1. A setting that the native solver does not support raises + ``NotImplementedError``. The message names the setting. +2. When the native core is not installed, choosing ``"native"`` raises + ``ImportError`` with the install hint ``pip install loop-interpolation[native]``. + +These tests do not need ``loop_interpolation_core``. The unsupported-setting +checks run before the core import. The missing-core check blocks the import +with ``sys.modules``. +""" + +import re +import sys + +import numpy as np +import pytest +from loop_interpolation import FiniteDifferenceInterpolator, StructuredGrid + + +def _make_grid() -> StructuredGrid: + return StructuredGrid( + origin=np.array([0.0, 0.0, 0.0]), + nsteps=np.array([5, 5, 5]), + step_vector=np.array([1.0, 1.0, 1.0]), + ) + + +def _region_mask(interp: FiniteDifferenceInterpolator) -> np.ndarray: + # A boolean mask over all support nodes. All nodes are active. + return np.ones(interp.support.n_nodes, dtype=bool) + + +def _build_interpolator() -> FiniteDifferenceInterpolator: + interp = FiniteDifferenceInterpolator(_make_grid()) + # Five columns: X, Y, Z, value, weight. + interp.set_value_constraints( + np.array( + [ + [1.0, 1.0, 1.0, 0.0, 1.0], + [2.0, 2.0, 2.0, 1.0, 1.0], + [3.0, 2.0, 1.0, 0.5, 1.0], + ] + ) + ) + return interp + + +# Each case gives: +# setup_kwargs - keywords for setup_interpolator. A callable value +# receives the interpolator and returns the real value. +# attributes - attributes set on the interpolator before setup. +# solver_kwargs - keywords for solve_system. +# Column scaling (apply_scaling_matrix) is not in this list. The native solver +# ignores it for any value (see the Answers in plan.md). +UNSUPPORTED_SETTINGS = [ + pytest.param( + "region", + {"setup_kwargs": {"region": _region_mask}}, + id="region", + ), + pytest.param( + "use_regularisation_weight_scale", + {"setup_kwargs": {"use_regularisation_weight_scale": True}}, + id="use_regularisation_weight_scale", + ), + pytest.param( + "constant_norm_iterations", + {"solver_kwargs": {"constant_norm_iterations": 3}}, + id="constant_norm_iterations", + ), + pytest.param( + "interface_pair_mode", + {"setup_kwargs": {"interface_pair_mode": "all"}}, + id="interface_pair_mode", + ), + pytest.param( + "regularisation_matrix_free", + { + "setup_kwargs": {"regularisation_matrix_free": True}, + # The matrix-free flag is dropped when apply_scaling_matrix is True. + "attributes": {"apply_scaling_matrix": False}, + }, + id="regularisation_matrix_free", + ), +] + + +@pytest.mark.parametrize("setting, config", UNSUPPORTED_SETTINGS) +def test_unsupported_setting_with_native_raises_not_implemented(setting, config): + interp = _build_interpolator() + + for name, value in config.get("attributes", {}).items(): + setattr(interp, name, value) + + setup_kwargs = { + key: (value(interp) if callable(value) else value) + for key, value in config.get("setup_kwargs", {}).items() + } + interp.setup_interpolator(**setup_kwargs) + + solver_kwargs = dict(config.get("solver_kwargs", {})) + + # The message must name the unsupported setting, so the user can find it. + with pytest.raises(NotImplementedError, match=re.escape(setting)): + interp.solve_system(solver="native", solver_kwargs=solver_kwargs) + + +def test_native_without_core_raises_import_error_with_install_hint(monkeypatch): + # A None entry in sys.modules makes "import loop_interpolation_core" raise + # ImportError. This works even when the core is installed. + monkeypatch.setitem(sys.modules, "loop_interpolation_core", None) + + interp = _build_interpolator() + interp.setup_interpolator() + + with pytest.raises(ImportError, match=re.escape("pip install loop-interpolation[native]")): + interp.solve_system(solver="native") + + +# --- Interpolator classes and sparse-only rows (review finding 1) --- + + +def _fold_grid_data(): + return np.array([[1.0, 1.0, 1.0, 0.0, 1.0], [2.0, 2.0, 2.0, 1.0, 1.0]]) + + +def test_native_rejects_p1_interpolator(): + from loop_common.supports import TetMesh + from loop_interpolation import PiecewiseLinearInterpolator as P1 + + interp = P1(TetMesh()) + with pytest.raises(NotImplementedError, match="FiniteDifferenceInterpolator"): + interp.solve_system(solver="native") + + +def test_native_rejects_fd_fold_subclass(): + from loop_interpolation import FDFoldInterpolator + + interp = FDFoldInterpolator(_make_grid()) + with pytest.raises(NotImplementedError, match="FDFoldInterpolator"): + interp.solve_system(solver="native") + + +def test_native_accepts_subclass_with_flag(): + from loop_interpolation import _native + + class Flagged(FiniteDifferenceInterpolator): + supports_native = True + + _native.check_supported(Flagged(_make_grid()), {}, {}) + _native.check_supported(FiniteDifferenceInterpolator(_make_grid()), {}, {}) + + +def test_native_rejects_subclass_without_flag(): + class Plain(FiniteDifferenceInterpolator): + pass + + with pytest.raises(NotImplementedError, match="Plain"): + Plain(_make_grid()).solve_system(solver="native") + + +def test_native_rejects_subclass_of_flagged_subclass(): + class Flagged(FiniteDifferenceInterpolator): + supports_native = True + + class Child(Flagged): + pass + + with pytest.raises(NotImplementedError, match="Child"): + Child(_make_grid()).solve_system(solver="native") + + +def test_native_rejects_sparse_only_least_squares_rows(): + interp = _build_interpolator() + interp.solver = "native" + interp.setup_interpolator() + interp.add_constraints_to_least_squares( + np.array([[1.0]]), np.array([0.0]), np.array([[0]]), name="extra" + ) + with pytest.raises(NotImplementedError, match="add_constraints_to_least_squares"): + interp.solve_system(solver="native") + + +def test_native_rejects_extra_rows_after_matrix_setup(): + interp = _build_interpolator() + interp.solver = "cg" + interp.setup_interpolator() + assert len(interp.constraints) > 0 + interp.add_constraints_to_least_squares( + np.array([[1.0]]), np.array([0.0]), np.array([[0]]), name="extra" + ) + with pytest.raises(NotImplementedError, match="add_constraints_to_least_squares"): + interp.solve_system(solver="native") + + +def test_native_setup_then_matrix_solver_raises_value_error(): + interp = _build_interpolator() + interp.solver = "native" + interp.setup_interpolator() + with pytest.raises(ValueError, match="setup"): + interp.solve_system(solver="cg") + # The failed call must not destroy the old field. + assert not np.all(np.isnan(interp.c)) + + +def test_native_setup_keeps_setup_kwargs_and_builds_no_rows(): + interp = _build_interpolator() + interp.solver = "native" + interp.setup_interpolator(cpw=2.0) + assert interp._setup_kwargs == {"cpw": 2.0} + assert interp._native_setup + assert len(interp.constraints) == 0 + assert len(interp.ineq_constraints) == 0 + assert interp._setup_constraint_names == frozenset() + + +def test_second_setup_gives_new_snapshot_and_flag(): + interp = _build_interpolator() + interp.solver = "native" + interp.setup_interpolator() + assert interp._native_setup + # A new setup with a matrix solver clears the flag and records the new rows. + interp.solver = "cg" + interp.setup_interpolator() + assert not interp._native_setup + assert interp._setup_constraint_names == frozenset(interp.constraints) + assert len(interp._setup_constraint_names) > 0 + assert interp.solve_system(solver="cg") in (True, False) + # reset() clears the flag and the snapshot. + interp.solver = "native" + interp.setup_interpolator() + interp.reset() + assert not interp._native_setup + assert interp._setup_constraint_names is None + + +def test_native_rejects_inequality_feature(): + interp = _build_interpolator() + interp.solver = "native" + interp.setup_interpolator() + interp.add_inequality_feature(lambda xyz: xyz[:, 2], lower=True) + with pytest.raises(NotImplementedError, match="add_inequality_feature"): + interp.solve_system(solver="native") + + +def test_native_accepts_normal_data_rows_after_native_setup(): + pytest.importorskip("loop_interpolation_core") + interp = _build_interpolator() + interp.solver = "native" + interp.setup_interpolator() + assert interp.solve_system(solver="native") in (True, False) diff --git a/packages/loop_interpolation/tests/test_native_inequality.py b/packages/loop_interpolation/tests/test_native_inequality.py new file mode 100644 index 00000000..344ac64b --- /dev/null +++ b/packages/loop_interpolation/tests/test_native_inequality.py @@ -0,0 +1,347 @@ +"""Inequality problems: the native solver must agree with the Python ADMM solver. + +Story 6 scenario 1, SC-004. Two cases on a 20^3 grid: value bounds and pair bounds. +For each case, two things are checked: + 1. Each solver keeps the constraint violation below 1e-4 of the field range. + 2. The two fields differ by less than 1e-3 of the field range. +All tests skip while the native core (loop_interpolation_core) is not installed. +""" + +import numpy as np +import pytest + +# Skip the whole file when the native core is not installed. +pytest.importorskip("loop_interpolation_core") + +from loop_interpolation import FiniteDifferenceInterpolator as FDI # noqa: E402 +from loop_interpolation import StructuredGrid # noqa: E402 + +VIOLATION_TOL = 1e-4 # fraction of the field range +FIELD_TOL = 1e-3 # fraction of the field range +PAIR_UPPER_BOUND = 1e-6 # value for setup kwarg inequality_pair_upper_bound + + +# Settings for each solver. Both use tight ADMM tolerances, +# so that the bounds are met to the limit of this test. +SOLVER_KWARGS = { + "native": {"maxiter": 50000, "admm_abs_tol": 1e-7, "admm_rel_tol": 1e-7}, + # Weight 1.0 makes the bound penalty strong enough for the Python ADMM. + "admm": { + "nmajor": 500, + "admm_weight": 1.0, + "admm_abs_tol": 1e-7, + "admm_rel_tol": 1e-7, + "atol": 1e-10, + "btol": 1e-10, + "maxiter": 5000, + }, +} + + +def _make_interpolator(solver): + """Make a 20^3 structured grid interpolator that uses the given solver.""" + origin = np.array([-0.1, -0.1, -0.1]) + maximum = np.array([1.1, 1.1, 1.1]) + nsteps = np.array([20, 20, 20]) + grid = StructuredGrid(origin=origin, nsteps=nsteps, step_vector=(maximum - origin) / nsteps) + interpolator = FDI(grid) + # Set the solver before setup, so the native path can skip the sparse rows. + interpolator.solver = solver + # The Python path scales the columns of the main matrix but not the bound matrix, + # so the bounds would apply to scaled values. Switch the scaling off for a fair oracle. + interpolator.apply_scaling_matrix = False + return interpolator + + +def _add_ramp_data(interpolator): + """Add 60 value rows from a smooth field that rises with z (about 0 to 1). + + The many rows make the unbounded field well determined, + so both solvers can reach the bounds. + """ + rng = np.random.default_rng(0) + xyz = rng.uniform(0.1, 0.9, size=(60, 3)) + values = xyz[:, 2] + 0.1 * np.sin(2.0 * xyz[:, 0]) * xyz[:, 1] + interpolator.set_value_constraints(np.column_stack([xyz, values, np.ones(60)])) + + +def _evaluation_points(): + """Points on a regular grid in the unit cube, used to measure the field.""" + axis = np.linspace(0.05, 0.95, 12) + return np.array(np.meshgrid(axis, axis, axis, indexing="ij")).reshape(3, -1).T + + +def _check_converged(interpolator, solver): + """The native core must report convergence.""" + if solver == "native": + report = interpolator.latest_native_report + assert report["converged"], f"native did not converge: {report}" + + +def _solve_value_bounds(solver): + """Ramp field with value bounds that cut the field in the middle.""" + interpolator = _make_interpolator(solver) + _add_ramp_data(interpolator) + # Columns: X, Y, Z, lower, upper, weight. The upper bound is below the ramp value. + bound_xyz = np.array( + [[0.3, 0.3, 0.7], [0.5, 0.5, 0.7], [0.7, 0.7, 0.7], [0.4, 0.6, 0.8], [0.6, 0.4, 0.8]] + ) + bounds = np.column_stack( + [bound_xyz, np.full(5, -5.0), np.full(5, 0.5), np.ones(5)] + ) + interpolator.set_value_inequality_constraints(bounds) + interpolator.setup() + interpolator.solve_system(solver=solver, solver_kwargs=dict(SOLVER_KWARGS[solver])) + _check_converged(interpolator, solver) + return interpolator, bound_xyz + + +def _solve_pair_bounds(solver): + """Ramp field with pair bounds: every point of rock 0 must be below rock 1.""" + interpolator = _make_interpolator(solver) + _add_ramp_data(interpolator) + # Columns: X, Y, Z, rock_id, weight. Rock 0 is high in z and rock 1 is low in z, + # so the ramp breaks the bound and the solver must act. + rock0 = np.array([[0.3, 0.3, 0.8], [0.5, 0.5, 0.8], [0.7, 0.7, 0.8]]) + rock1 = np.array([[0.3, 0.7, 0.3], [0.5, 0.5, 0.3], [0.7, 0.3, 0.3]]) + pairs = np.vstack( + [ + np.column_stack([rock0, np.zeros(3), np.ones(3)]), + np.column_stack([rock1, np.ones(3), np.ones(3)]), + ] + ) + interpolator.set_inequality_pairs_constraints(pairs) + interpolator.setup( + inequality_pairs=[(0, 1)], + inequality_pair_upper_bound=PAIR_UPPER_BOUND, + inequality_pair_lower_bound=-np.inf, + ) + interpolator.solve_system(solver=solver, solver_kwargs=dict(SOLVER_KWARGS[solver])) + _check_converged(interpolator, solver) + return interpolator, rock0, rock1 + + +def _field_range(field): + return float(np.nanmax(field) - np.nanmin(field)) + + +def test_native_matches_admm_with_value_bounds(): + native, bound_xyz = _solve_value_bounds("native") + admm, _ = _solve_value_bounds("admm") + points = _evaluation_points() + field_native = native.evaluate_value(points) + field_admm = admm.evaluate_value(points) + value_range = _field_range(field_admm) + assert value_range > 0 + + for name, solved in (("native", native), ("admm", admm)): + at_bounds = solved.evaluate_value(bound_xyz) + # The upper bound is 0.5 and the lower bound is -5 (far below the field). + violation = max(float(np.max(at_bounds - 0.5)), 0.0) + assert violation <= VIOLATION_TOL * value_range, f"{name}: violation {violation}" + + difference = float(np.nanmax(np.abs(field_native - field_admm))) + assert difference <= FIELD_TOL * value_range, f"max field difference {difference}" + + +def test_native_matches_admm_with_pair_bounds(): + native, rock0, rock1 = _solve_pair_bounds("native") + admm, _, _ = _solve_pair_bounds("admm") + points = _evaluation_points() + field_native = native.evaluate_value(points) + field_admm = admm.evaluate_value(points) + value_range = _field_range(field_admm) + assert value_range > 0 + + for name, solved in (("native", native), ("admm", admm)): + upper = solved.evaluate_value(rock0) + lower = solved.evaluate_value(rock1) + # Each row needs f(rock 0 point) - f(rock 1 point) <= upper bound, + # for all pairs of points. + violation = max(float(np.max(upper) - np.min(lower)) - PAIR_UPPER_BOUND, 0.0) + assert violation <= VIOLATION_TOL * value_range, f"{name}: violation {violation}" + + difference = float(np.nanmax(np.abs(field_native - field_admm))) + assert difference <= FIELD_TOL * value_range, f"max field difference {difference}" + + +# --- Cap on the pair-bound rows (finding 5, decision D-R1) --------------------- + + +class _CountingProblem: + """Stub of the core problem. It keeps the pair rows and the largest chunk.""" + + def __init__(self, keep_rows=False): + self.keep_rows = keep_rows + self.n_rows = 0 + self.max_chunk = 0 + self.calls = 0 + self.a = [] + self.b = [] + + def add_value_bound(self, *args): + pass + + def add_pair_bound(self, points_a, points_b, lower, upper, weights): + self.calls += 1 + self.n_rows += lower.size + self.max_chunk = max(self.max_chunk, lower.size) + if self.keep_rows: + self.a.append(points_a.reshape(-1, 3).copy()) + self.b.append(points_b.reshape(-1, 3).copy()) + + +class _StubInterpolator: + def __init__(self, pairs): + self._pairs = pairs + + def get_inequality_value_constraints(self): + return None + + def get_inequality_pairs_constraints(self): + return self._pairs + + +class _RecordingLogger: + """Replace the module logger. The loop_common logger does not propagate to caplog.""" + + def __init__(self): + self.warnings = [] + + def warning(self, message, *args): + self.warnings.append(message % args) + + def debug(self, *args, **kwargs): + pass + + info = debug + + +def _two_rock_pairs(n_each, seed=1): + rng = np.random.default_rng(seed) + a = np.column_stack([rng.uniform(0, 1, (n_each, 3)), np.zeros(n_each), np.ones(n_each)]) + b = np.column_stack([rng.uniform(0, 1, (n_each, 3)), np.ones(n_each), np.ones(n_each)]) + return np.vstack([a, b]) + + +def test_pair_bounds_cap_keeps_memory_low_and_warns(monkeypatch): + import tracemalloc + + from loop_interpolation import _native + + recorder = _RecordingLogger() + monkeypatch.setattr(_native, "logger", recorder) + interpolator = _StubInterpolator(_two_rock_pairs(5000)) # 25 million combinations + problem = _CountingProblem() + tracemalloc.start() + try: + _native._add_inequalities(problem, interpolator, {}) + _, peak = tracemalloc.get_traced_memory() + finally: + tracemalloc.stop() + assert peak < 200 * 1024**2, f"peak {peak / 1024**2:.0f} MB" + assert problem.n_rows == 100_000 + assert problem.max_chunk <= 100_000 + assert len(recorder.warnings) == 1 + text = recorder.warnings[0] + assert "25000000" in text and "100000" in text + + +def test_pair_bounds_below_cap_send_all_combinations(monkeypatch): + from loop_interpolation import _native + + recorder = _RecordingLogger() + monkeypatch.setattr(_native, "logger", recorder) + data = _two_rock_pairs(7) + problem = _CountingProblem(keep_rows=True) + _native._add_inequalities(problem, _StubInterpolator(data), {}) + assert not recorder.warnings + a = np.vstack(problem.a) + b = np.vstack(problem.b) + # The matrix path pairs every point of the first id with every point of the second. + expected_a = np.repeat(data[:7, :3], 7, axis=0) + expected_b = np.tile(data[7:, :3], (7, 1)) + np.testing.assert_array_equal(a, expected_a) + np.testing.assert_array_equal(b, expected_b) + + +def test_pair_bounds_sample_is_repeatable_and_inside_the_product(): + from loop_interpolation import _native + + def rows(seed): + problem = _CountingProblem(keep_rows=True) + setup = {"inequality_pair_max_rows": 50} + if seed is not None: + setup["inequality_pair_seed"] = seed + _native._add_inequalities(problem, _StubInterpolator(_two_rock_pairs(30)), setup) + return np.vstack(problem.a), np.vstack(problem.b) + + first, second = rows(None), rows(None) + np.testing.assert_array_equal(first[0], second[0]) + np.testing.assert_array_equal(first[1], second[1]) + assert first[0].shape[0] == 50 + # No combination appears twice. + assert np.unique(np.hstack(first), axis=0).shape[0] == 50 + other = rows(5) + assert not np.array_equal(first[0], other[0]) or not np.array_equal(first[1], other[1]) + + +def test_pair_bounds_bad_cap_is_rejected(): + from loop_interpolation import _native + + with pytest.raises(ValueError, match="inequality_pair_max_rows"): + _native._add_inequalities( + _CountingProblem(), + _StubInterpolator(_two_rock_pairs(3)), + {"inequality_pair_max_rows": 0}, + ) + + +def test_pair_bounds_nan_rows_are_dropped(): + from loop_interpolation import _native + + data = _two_rock_pairs(4) + data[0, 0] = np.nan + problem = _CountingProblem() + _native._add_inequalities(problem, _StubInterpolator(data), {}) + assert problem.n_rows == 3 * 4 + + +def test_native_with_cap_above_size_matches_admm_and_repeats(): + # Cap above the size of the product: same field as the ADMM oracle. + def solve(solver, **extra): + interpolator = _make_interpolator(solver) + _add_ramp_data(interpolator) + rock0 = np.array([[0.3, 0.3, 0.8], [0.5, 0.5, 0.8], [0.7, 0.7, 0.8]]) + rock1 = np.array([[0.3, 0.7, 0.3], [0.5, 0.5, 0.3], [0.7, 0.3, 0.3]]) + pairs = np.vstack( + [ + np.column_stack([rock0, np.zeros(3), np.ones(3)]), + np.column_stack([rock1, np.ones(3), np.ones(3)]), + ] + ) + interpolator.set_inequality_pairs_constraints(pairs) + interpolator.setup( + inequality_pairs=[(0, 1)], + inequality_pair_upper_bound=PAIR_UPPER_BOUND, + inequality_pair_lower_bound=-np.inf, + **extra, + ) + interpolator.solve_system(solver=solver, solver_kwargs=dict(SOLVER_KWARGS[solver])) + return interpolator.evaluate_value(_evaluation_points()) + + first = solve("native", inequality_pair_max_rows=1000) + second = solve("native", inequality_pair_max_rows=1000) + admm = solve("admm") + scale = _field_range(admm) + assert float(np.max(np.abs(first - admm))) <= FIELD_TOL * scale + # The same seed gives the same rows. The parallel sums of the core do + # not add in a fixed order, so two runs can differ in the last bits + # (SC-003). ADMM stops at its tolerance, so the difference can grow to + # about that tolerance: on macOS it was 2.5e-8. Use the SC-003 limit. + np.testing.assert_allclose(first, second, rtol=0, atol=1e-6 * scale) + + # A cap below the size of the product (4 of 9 rows) is repeatable with the same seed. + capped_1 = solve("native", inequality_pair_max_rows=4) + capped_2 = solve("native", inequality_pair_max_rows=4) + np.testing.assert_allclose(capped_1, capped_2, rtol=0, atol=1e-6 * scale) diff --git a/packages/loop_interpolation/tests/test_native_no_rows.py b/packages/loop_interpolation/tests/test_native_no_rows.py new file mode 100644 index 00000000..893df038 --- /dev/null +++ b/packages/loop_interpolation/tests/test_native_no_rows.py @@ -0,0 +1,263 @@ +"""Tests for the native solver and the sparse rows (FR-019, plan D7.2). + +Tests (a) to (c) need the native core and skip when it is not installed. +Test (d) does not need the core. +""" + +import logging +import subprocess +import sys + +import numpy as np +import pytest +from loop_interpolation import ( + ConstraintDiagnosticsReport, + FiniteDifferenceInterpolator, + StructuredGrid, +) + +SETUP_KWARGS = {"cpw": 1.0, "gpw": 0.0, "npw": 0.0, "tpw": 0.0, "ipw": 0.0} + + +def _make_interpolator(solver="cg"): + grid = StructuredGrid( + origin=np.array([0.0, 0.0, 0.0]), + nsteps=np.array([6, 6, 6]), + step_vector=np.array([1.0, 1.0, 1.0]), + ) + interpolator = FiniteDifferenceInterpolator(grid) + # The solver is set before setup. Plain attribute, as in the factory. + interpolator.solver = solver + points = np.array( + [ + [1.5, 2.5, 2.5, 0.0, 1.0], + [4.5, 2.5, 2.5, 1.0, 1.0], + [2.5, 3.5, 1.5, 0.5, 1.0], + [3.5, 1.5, 4.5, 0.7, 1.0], + ] + ) + interpolator.set_value_constraints(points) + return interpolator + + +def _field(interpolator): + return np.asarray(interpolator.c, dtype=float).copy() + + +def test_native_before_setup_builds_no_rows(): + """(a) Native solver set before setup: no sparse rows, report exists.""" + pytest.importorskip("loop_interpolation_core") + interpolator = _make_interpolator("native") + report = interpolator.setup(**SETUP_KWARGS) + assert len(interpolator.constraints) == 0 + assert isinstance(report, ConstraintDiagnosticsReport) + assert interpolator.latest_diagnostics_report is report + + +def test_native_after_setup_works_with_one_warning(caplog): + """(b) Native solver set after setup: rows exist, field is correct, one warning.""" + pytest.importorskip("loop_interpolation_core") + reference = _make_interpolator("cg") + reference.setup(**SETUP_KWARGS) + reference.solve_system("cg", tol=1e-12) + expected = _field(reference) + + interpolator = _make_interpolator("cg") + interpolator.setup(**SETUP_KWARGS) + assert len(interpolator.constraints) > 0 + with caplog.at_level(logging.WARNING): + interpolator.solve_system("native") + memory_warnings = [ + r + for r in caplog.records + if r.levelno == logging.WARNING and "memory" in r.getMessage().lower() + ] + assert len(memory_warnings) == 1 + assert len(interpolator.constraints) > 0 + scale = max(float(np.ptp(expected)), 1e-12) + assert np.max(np.abs(_field(interpolator) - expected)) <= 1e-3 * scale + + +def test_update_after_native_setup_keeps_field(): + """(c) update() after (a) does not raise and the field is the same.""" + pytest.importorskip("loop_interpolation_core") + interpolator = _make_interpolator("native") + interpolator.setup(**SETUP_KWARGS) + interpolator._setup_kwargs = dict(SETUP_KWARGS) + interpolator.solve_system("native") + first = _field(interpolator) + interpolator.up_to_date = False + interpolator.update() + assert len(interpolator.constraints) == 0 + assert np.allclose(_field(interpolator), first, rtol=1e-9, atol=1e-9) + + +def test_native_not_converged_keeps_old_field(): + """A solve that does not converge returns False and keeps the old ``c``.""" + pytest.importorskip("loop_interpolation_core") + interpolator = _make_interpolator("native") + interpolator.setup(**SETUP_KWARGS) + interpolator._setup_kwargs = dict(SETUP_KWARGS) + old = np.full(interpolator.support.n_nodes, 7.0) + interpolator.c = old.copy() + # Call solve directly. This tests the adapter without solve_system. + from loop_interpolation import _native + + converged = _native.solve(interpolator, None, {"maxiter": 1}) + assert converged is False + assert np.array_equal(interpolator.c, old) + report = interpolator.latest_native_report + assert report["converged"] is False + partial = np.asarray(report["field"]) + assert partial.shape[0] == interpolator.support.n_nodes + assert not np.array_equal(partial, old) + + +def test_native_via_solve_system_keeps_old_field_when_not_converged(): + """``solve_system`` keeps the old ``c`` when the native solve stops early.""" + pytest.importorskip("loop_interpolation_core") + interpolator = _make_interpolator("native") + interpolator.setup(**SETUP_KWARGS) + interpolator._setup_kwargs = dict(SETUP_KWARGS) + assert interpolator.solve_system("native") is True + old = _field(interpolator) + converged = interpolator.solve_system("native", solver_kwargs={"maxiter": 1}) + assert converged is False + assert interpolator.up_to_date is False + assert np.array_equal(interpolator.c, old) + assert "field" in interpolator.latest_native_report + + +def test_native_after_cg_resets_history_and_timing(): + """A native solve after a cg solve clears the cg history and timing keys.""" + pytest.importorskip("loop_interpolation_core") + interpolator = _make_interpolator("cg") + interpolator.setup(**SETUP_KWARGS) + interpolator.solve_system("cg", tol=1e-8) + cg_keys = set(interpolator.latest_solve_timing) - { + "solver", + "total", + "total_seconds", + "iterations", + "up_to_date", + } + assert cg_keys + interpolator.solver_history = [1.0] + assert interpolator.solve_system("native") is True + assert interpolator.solver_history is None + timing = interpolator.latest_solve_timing + assert timing["solver"] == "native" + assert not (set(timing) & cg_keys) + + +def test_native_converged_report_has_no_field(): + """A converged solve writes ``c`` and keeps the field out of the report.""" + pytest.importorskip("loop_interpolation_core") + interpolator = _make_interpolator("native") + interpolator.setup(**SETUP_KWARGS) + interpolator._setup_kwargs = dict(SETUP_KWARGS) + assert interpolator.solve_system("native") is True + assert "field" not in interpolator.latest_native_report + assert interpolator.c.shape[0] == interpolator.support.n_nodes + + +def test_native_f32_precision_gives_float64_field(): + """The f32 solve returns a float64 ``c``.""" + pytest.importorskip("loop_interpolation_core") + from loop_interpolation import _native + + interpolator = _make_interpolator("native") + interpolator.setup(**SETUP_KWARGS) + interpolator._setup_kwargs = dict(SETUP_KWARGS) + assert _native.solve(interpolator, None, {"precision": "f32", "tol": 1e-3}) is True + assert interpolator.c.dtype == np.float64 + + +@pytest.mark.parametrize("converged", [True, False]) +def test_native_wrong_field_length_raises(monkeypatch, converged): + """A field with the wrong length raises RuntimeError and keeps ``c``.""" + import types + + from loop_interpolation import _native + + class FakeProblem: + def __init__(self, *args): + pass + + def __getattr__(self, name): + return lambda *args, **kwargs: None + + def solve(self, settings): + return np.zeros(3, dtype=np.float32), {"converged": converged} + + fake = types.SimpleNamespace(Problem=FakeProblem) + monkeypatch.setitem(sys.modules, "loop_interpolation_core", fake) + monkeypatch.setattr(_native, "_core_settings", lambda *args, **kwargs: {}) + interpolator = _make_interpolator("native") + interpolator.setup(**SETUP_KWARGS) + interpolator._setup_kwargs = dict(SETUP_KWARGS) + old = np.full(interpolator.support.n_nodes, 7.0) + interpolator.c = old.copy() + interpolator.latest_native_report = None + with pytest.raises(RuntimeError, match="3"): + _native.solve(interpolator, None, {}) + assert np.array_equal(interpolator.c, old) + assert interpolator.latest_native_report is None + + +def test_import_does_not_import_core(): + """(d) Import of loop_interpolation never imports the core.""" + code = ( + "import sys, loop_interpolation; " + "bad = [m for m in sys.modules if m.startswith('loop_interpolation_core')]; " + "sys.exit(1 if bad else 0)" + ) + result = subprocess.run([sys.executable, "-c", code], capture_output=True) + assert result.returncode == 0, result.stderr.decode() + + +@pytest.mark.parametrize("solver", ["cg", "lsmr"]) +def test_existing_solvers_unchanged_without_core(solver): + """(d) cg and lsmr give the same field, with no core needed.""" + reference = _make_interpolator("cg") + reference.setup(**SETUP_KWARGS) + reference.solve_system("cg", tol=1e-12) + expected = _field(reference) + + interpolator = _make_interpolator(solver) + interpolator.setup(**SETUP_KWARGS) + assert len(interpolator.constraints) > 0 + interpolator.solve_system(solver) + field = _field(interpolator) + assert np.all(np.isfinite(field)) + scale = max(float(np.ptp(expected)), 1e-12) + assert np.max(np.abs(field - expected)) <= 1e-2 * scale + + +def test_admm_solver_unchanged_without_core(): + """(d) The admm solver runs and gives the same field as cg, with no core. + + The ADMM path needs inequality rows, so the test calls the solver + on a small box-bounded problem and compares with a direct solve. + """ + from loop_interpolation.loopsolver import admm_solve + from scipy import sparse + + rng = np.random.default_rng(0) + A = sparse.csr_matrix(rng.normal(size=(30, 8))) + x_true = np.linspace(0.1, 0.8, 8) + b = A @ x_true + Q = sparse.identity(8, format="csr") + bounds = np.array([[-10.0, 10.0]] * 8) + x = admm_solve( + A, + b, + Q, + bounds, + np.zeros(8), + admm_weight=0.05, + nmajor=200, + linsys_solver="cg", + ) + assert np.all(np.isfinite(x)) + assert np.allclose(x, x_true, atol=1e-2) diff --git a/packages/loop_interpolation/tests/test_native_parity.py b/packages/loop_interpolation/tests/test_native_parity.py new file mode 100644 index 00000000..1a5bca43 --- /dev/null +++ b/packages/loop_interpolation/tests/test_native_parity.py @@ -0,0 +1,342 @@ +"""Parity tests: native solver against the Python matrix solver (T027). + +Each test builds one problem twice with ``InterpolatorBuilder``. One build uses +``solver="cg"`` (matrix solver, tight tolerance). The other build uses +``solver="native"``. The two fields must differ by at most 1e-6 of the range. + +All tests skip when the native core is not installed. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +pytest.importorskip("loop_interpolation_core") + +from loop_interpolation import InterpolatorBuilder, InterpolatorType, StructuredGrid # noqa: E402 + +# Number of nodes on each axis (30 x 30 x 30 = 27000 nodes). +N_NODES = 30 +SEED = 20240607 +REL_TOL = 1e-6 +CG_KWARGS = {"rtol": 1e-12, "atol": 1e-12, "maxiter": 200000} +# The native solver gets the same tolerance and iteration limit as the reference. +NATIVE_KWARGS = {"tol": 1e-12, "maxiter": 50000} + +# Small background weight for the regularisation terms that are not under test. +# It keeps the problem well posed when one term is tested alone. +BACKGROUND = 0.05 +ALL_REGULARISATION = ("dxx", "dyy", "dzz", "dxy", "dxz", "dyz") +ZERO_DATA_WEIGHTS = {"cpw": 0.0, "gpw": 0.0, "npw": 0.0, "tpw": 0.0, "ipw": 0.0} + + +def _make_grid(n_nodes: int = N_NODES) -> StructuredGrid: + # nsteps_cells is the number of cells, so the node count is n_nodes. + return StructuredGrid( + origin=np.zeros(3), + nsteps_cells=np.full(3, n_nodes - 1), + step_vector=np.ones(3), + ) + + +def _field(p: np.ndarray) -> np.ndarray: + x, y, z = (p[:, i] / (N_NODES - 1) for i in range(3)) + return 3.0 * x + 1.5 * np.sin(2.0 * y) + 2.0 * z * z + 0.5 * x * y + + +def _field_gradient(p: np.ndarray) -> np.ndarray: + x, y, z = (p[:, i] / (N_NODES - 1) for i in range(3)) + g = np.column_stack([3.0 + 0.5 * y, 3.0 * np.cos(2.0 * y) + 0.5 * x, 4.0 * z]) + return g / (N_NODES - 1) + + +def _points(rng, n: int) -> np.ndarray: + # All data is inside the grid, away from the border. + return rng.uniform(2.0, N_NODES - 3.0, size=(n, 3)) + + +def _data(family: str) -> dict: + """Return the constraint arrays for one family (set-method name -> array).""" + rng = np.random.default_rng(SEED) + value_pts = _points(rng, 80) + value = np.column_stack([value_pts, _field(value_pts), rng.uniform(0.5, 2.0, 80)]) + if family == "value": + return {"add_value_constraints": value} + if family == "gradient": + p = _points(rng, 30) + g = _field_gradient(p) + g /= np.linalg.norm(g, axis=1)[:, None] + return {"add_value_constraints": value, "add_gradient_constraints": np.column_stack([p, g])} + if family == "normal": + p = _points(rng, 30) + n = _field_gradient(p) + return { + "add_value_constraints": value, + "add_normal_constraints": np.column_stack([p, n, np.ones(len(p))]), + } + if family == "tangent": + p = _points(rng, 30) + t = np.cross(_field_gradient(p), np.tile([0.0, 0.0, 1.0], (len(p), 1))) + t /= np.linalg.norm(t, axis=1)[:, None] + return {"add_value_constraints": value, "add_tangent_constraints": np.column_stack([p, t])} + if family == "interface": + p = _points(rng, 12) + ids = np.repeat(np.arange(4, dtype=float), 3) + return { + "add_value_constraints": value, + "set_interface_constraints": np.column_stack([p, ids]), + } + # Regularisation and border families use value data only. + return {"add_value_constraints": value} + + +def _build(solver: str, data: dict, setup_kwargs: dict) -> np.ndarray: + builder = InterpolatorBuilder( + interpolatortype=InterpolatorType.FINITE_DIFFERENCE, + support=_make_grid(), + ) + if solver == "native": + builder.use_solver("native", **NATIVE_KWARGS) + else: + builder.use_solver(solver, **CG_KWARGS) + for method, array in data.items(): + if hasattr(builder, method): + getattr(builder, method)(array) + else: + # The builder has no adder for this family. Use the interpolator. + getattr(builder.interpolator, method)(array) + builder.setup_interpolator(interface_pair_mode="star", **setup_kwargs) + builder.solve() + interpolator = builder.build() + assert interpolator.up_to_date + return np.asarray(interpolator.c, dtype=float).copy() + + +def _assert_parity(data: dict, setup_kwargs: dict) -> None: + reference = _build("cg", data, setup_kwargs) + native = _build("native", data, setup_kwargs) + assert native.shape == reference.shape + span = float(np.ptp(reference)) + assert span > 0.0 + assert np.max(np.abs(native - reference)) <= REL_TOL * span + + +def _background_regularisation(**overrides) -> dict: + kwargs = {name: BACKGROUND for name in ALL_REGULARISATION} + kwargs.update(overrides) + return kwargs + + +@pytest.mark.parametrize("family", ["value", "gradient", "normal", "tangent", "interface"]) +def test_native_matches_cg_for_constraint_family(family): + # Default regularisation, one data family (plus the value data that fixes the level). + weights = {**ZERO_DATA_WEIGHTS, "cpw": 1.0} + if family != "value": + weights[{"gradient": "gpw", "normal": "npw", "tangent": "tpw", "interface": "ipw"}[family]] = 1.0 + _assert_parity(_data(family), weights) + + +@pytest.mark.parametrize("term", ALL_REGULARISATION) +def test_native_matches_cg_for_regularisation_term(term): + # One term has weight 1. The other terms have the small background weight. + kwargs = _background_regularisation(**{term: 1.0}) + _assert_parity(_data("value"), {**ZERO_DATA_WEIGHTS, "cpw": 1.0, **kwargs}) + + +def test_native_matches_cg_for_border_regularisation(): + kwargs = _background_regularisation(dxx=1.0, dyy=1.0, dzz=1.0, dx=1.0, dy=1.0, dz=1.0) + _assert_parity(_data("value"), {**ZERO_DATA_WEIGHTS, "cpw": 1.0, **kwargs}) + + +def _vertical(points: np.ndarray) -> np.ndarray: + return np.tile([0.0, 0.0, 1.0], (points.shape[0], 1)) + + +def test_native_matches_cg_for_directional_regularisation(): + regularisation = { + "isotropic": BACKGROUND, + "directional": [{"weight": 1.0, "direction": _vertical, "name": "vertical smoothing"}], + } + _assert_parity( + _data("value"), + {**ZERO_DATA_WEIGHTS, "cpw": 1.0, "regularisation": regularisation}, + ) + + +def test_native_node_order_matches_matrix_solver(): + # Field f = x + 2y + 3z. Value constraints at every node fix the field, + # so interpolator.c must equal f at the nodes in the node order of the grid. + n = 8 + grid = _make_grid(n) + nodes = np.asarray(grid.nodes, dtype=float) + expected = nodes[:, 0] + 2.0 * nodes[:, 1] + 3.0 * nodes[:, 2] + data = np.column_stack([nodes, expected, np.full(len(nodes), 1.0)]) + setup = {**ZERO_DATA_WEIGHTS, "cpw": 1.0, **_background_regularisation()} + # Weak regularisation: the data fixes the field. + setup.update({name: 1e-4 for name in ALL_REGULARISATION}) + + results = {} + for solver in ("cg", "native"): + builder = InterpolatorBuilder( + interpolatortype=InterpolatorType.FINITE_DIFFERENCE, + support=grid, + ) + if solver == "native": + builder.use_solver("native", **NATIVE_KWARGS) + else: + builder.use_solver(solver, **CG_KWARGS) + builder.add_value_constraints(data).setup_interpolator(**setup).solve() + results[solver] = np.asarray(builder.build().c, dtype=float).copy() + + span = float(np.ptp(expected)) + assert np.max(np.abs(results["cg"] - expected)) <= 1e-3 * span + assert np.max(np.abs(results["native"] - results["cg"])) <= REL_TOL * span + + +def _bad_row_data() -> dict: + """Good rows of every family plus rows that the matrix solver drops or tolerates.""" + data = _data("value") + rng = np.random.default_rng(SEED + 1) + value = data["add_value_constraints"] + bad_value = np.array( + [ + [10.0, 10.0, 10.0, 5.0, -1.0], # negative weight + [12.0, 11.0, 10.0, np.nan, 1.0], # nan value + [np.nan, 11.0, 10.0, 5.0, 1.0], # nan position + ] + ) + p = _points(rng, 30) + g = _field_gradient(p) + g /= np.linalg.norm(g, axis=1)[:, None] + bad_vectors = np.array([[0.0, 0.0, 0.0], [np.nan, 1.0, 0.0]]) + bad_p = _points(rng, 2) + gradient = np.vstack([np.column_stack([p, g]), np.column_stack([bad_p, bad_vectors])]) + normal_p = _points(rng, 30) + n = _field_gradient(normal_p) + normal = np.column_stack([normal_p, n, np.ones(len(normal_p))]) + bad_normal = np.column_stack( + [_points(rng, 3), np.array([[np.nan, 0, 1], [0, 0, 0], [0.1, 0.2, 0.3]]), [1.0, 1.0, -1.0]] + ) + t = np.cross(_field_gradient(p), np.tile([0.0, 0.0, 1.0], (len(p), 1))) + t /= np.linalg.norm(t, axis=1)[:, None] + tangent = np.vstack([np.column_stack([p, t]), np.column_stack([bad_p, bad_vectors])]) + return { + "add_value_constraints": np.vstack([value, bad_value]), + "add_gradient_constraints": gradient, + "add_normal_constraints": np.vstack([normal, bad_normal]), + "add_tangent_constraints": tangent, + } + + +def test_native_matches_cg_with_bad_data_rows(): + # Bad rows: negative weight, nan vector, nan value, nan position, zero vector. + # The native solver must not raise. The field must match the matrix solver. + weights = {**ZERO_DATA_WEIGHTS, "cpw": 1.0, "gpw": 1.0, "npw": 1.0, "tpw": 1.0} + reference = _build("cg", _bad_row_data(), weights) + native = _build("native", _bad_row_data(), weights) + assert np.all(np.isfinite(reference)) + assert np.all(np.isfinite(native)) + span = float(np.ptp(reference)) + assert np.max(np.abs(native - reference)) <= REL_TOL * span + + +def _bad_interface_data() -> dict: + """Interface groups of 3 points plus a nan id, a nan position and a one point group.""" + data = _data("interface") + rng = np.random.default_rng(SEED + 2) + extra = np.column_stack([_points(rng, 4), [np.nan, 0.0, 9.0, 4.0]]) + extra[1, 0] = np.nan # nan position inside group 0 + # Group 4 keeps one valid point only: the other point has a nan position. + extra[3, 1] = np.nan + extra = np.vstack( + [ + extra, + np.column_stack([_points(rng, 1), [4.0]]), + np.column_stack([_points(rng, 1), [9.0]]), + ] + ) + # The setter may drop some of these rows. Inject the rows after the setter. + data["_interface_rows"] = np.vstack([data.pop("set_interface_constraints"), extra]) + return data + + +def _build_interface(solver: str, data: dict, ipw) -> np.ndarray: + data = dict(data) + rows = data.pop("_interface_rows") + builder = InterpolatorBuilder( + interpolatortype=InterpolatorType.FINITE_DIFFERENCE, support=_make_grid() + ) + if solver == "native": + builder.use_solver("native", **NATIVE_KWARGS) + else: + builder.use_solver(solver, **CG_KWARGS) + for method, array in data.items(): + getattr(builder, method)(array) + # Inject the rows so that they reach the adapter and the matrix path as they are. + builder.interpolator.get_interface_constraints = lambda: rows + builder.setup_interpolator( + interface_pair_mode="star", **{**ZERO_DATA_WEIGHTS, "cpw": 1.0, "ipw": ipw} + ) + builder.solve() + return np.asarray(builder.build().c, dtype=float).copy() + + +@pytest.mark.parametrize("ipw", [1.0, -1.0]) +def test_native_matches_cg_with_bad_interface_rows(ipw): + # Bad rows: nan id, nan position, groups with one valid point. + # A negative ipw acts like its absolute value on the matrix path. + data = _bad_interface_data() + reference = _build_interface("cg", data, ipw) + native = _build_interface("native", data, ipw) + assert np.all(np.isfinite(native)) + span = float(np.ptp(reference)) + assert np.max(np.abs(native - reference)) <= REL_TOL * span + + +def test_native_negative_interface_weight_equals_absolute_weight(): + data = _bad_interface_data() + positive = _build_interface("native", data, 1.0) + negative = _build_interface("native", data, -1.0) + assert np.max(np.abs(positive - negative)) <= REL_TOL * float(np.ptp(positive)) + + +def test_native_interface_only_single_point_groups_adds_nothing(): + # Every group has one point. Nothing reaches the core. + from loop_interpolation import _native + + calls = [] + + class _Problem: + def add_interface(self, *args): + calls.append(args) + + class _Interp: + def __init__(self): + self.interpolation_weights = {"ipw": 1.0} + + def __getattr__(self, name): + return lambda: None + + def get_interface_constraints(self): + return np.array([[1.0, 1, 1, 0], [2, 2, 2, 1], [3, 3, 3, np.nan], [4, 4, 4, np.nan]]) + + _native._add_data(_Problem(), _Interp()) + assert calls == [] + + +def test_native_mixed_interface_weights_still_raise(): + from loop_interpolation import _native + + class _Interp: + def __init__(self): + self.interpolation_weights = {"ipw": np.array([1.0, 2.0, 1.0])} + + def __getattr__(self, name): + return lambda: None + + def get_interface_constraints(self): + return np.array([[1.0, 1, 1, 0], [2, 2, 2, 0], [3, 3, 3, 0]]) + + with pytest.raises(NotImplementedError): + _native._add_data(object(), _Interp()) diff --git a/packages/loop_interpolation/tests/test_native_warm_start_ridge.py b/packages/loop_interpolation/tests/test_native_warm_start_ridge.py new file mode 100644 index 00000000..0e1d20d7 --- /dev/null +++ b/packages/loop_interpolation/tests/test_native_warm_start_ridge.py @@ -0,0 +1,116 @@ +"""Warm start and ridge factor with solver="native". + +solve_system gives ``x0``/``warm_start`` and ``ridge_factor`` to the core. +A warm start must give the same field as a cold start. A large ridge +factor must pull the field to zero. +All tests skip while the native core (loop_interpolation_core) is not installed. +""" + +import numpy as np +import pytest + +# Skip the whole file when the native core is not installed. +pytest.importorskip("loop_interpolation_core") + +from loop_interpolation import FiniteDifferenceInterpolator as FDI +from loop_interpolation import StructuredGrid + +FIELD_TOL = 1e-6 # fraction of the field range + + +def _make_interpolator(bounds=False): + """Make a 20^3 grid with value data, set up for the native solver.""" + origin = np.array([-0.1, -0.1, -0.1]) + maximum = np.array([1.1, 1.1, 1.1]) + nsteps = np.array([20, 20, 20]) + grid = StructuredGrid(origin=origin, nsteps=nsteps, step_vector=(maximum - origin) / nsteps) + interpolator = FDI(grid) + interpolator.solver = "native" + rng = np.random.default_rng(0) + xyz = rng.uniform(0.1, 0.9, size=(60, 3)) + values = xyz[:, 2] + 0.1 * np.sin(2.0 * xyz[:, 0]) * xyz[:, 1] + interpolator.set_value_constraints(np.column_stack([xyz, values, np.ones(60)])) + if bounds: + bound_xyz = np.array([[0.5, 0.5, 0.7], [0.4, 0.6, 0.8]]) + interpolator.set_value_inequality_constraints( + np.column_stack([bound_xyz, np.full(2, -5.0), np.full(2, 0.5), np.ones(2)]) + ) + interpolator.setup() + return interpolator + + +def _range(c): + return float(np.max(c) - np.min(c)) + + +def test_native_warm_start_needs_fewer_iterations(): + interpolator = _make_interpolator() + assert interpolator.solve_system("native") + cold = interpolator.c.copy() + cold_iterations = interpolator.latest_native_report["iterations"] + + assert interpolator.solve_system("native", solver_kwargs={"warm_start": True}) + warm_iterations = interpolator.latest_native_report["iterations"] + assert warm_iterations * 10 < cold_iterations, (warm_iterations, cold_iterations) + assert np.max(np.abs(interpolator.c - cold)) <= FIELD_TOL * _range(cold) + + +def test_native_x0_gives_the_same_field(): + interpolator = _make_interpolator() + assert interpolator.solve_system("native") + cold = interpolator.c.copy() + + rough = np.sin(np.arange(interpolator.support.n_nodes, dtype=float)) + assert interpolator.solve_system("native", solver_kwargs={"x0": rough}) + assert np.max(np.abs(interpolator.c - cold)) <= FIELD_TOL * _range(cold) + + +def test_native_warm_start_with_bounds(): + interpolator = _make_interpolator(bounds=True) + kwargs = {"maxiter": 50000, "admm_abs_tol": 1e-7, "admm_rel_tol": 1e-7} + assert interpolator.solve_system("native", solver_kwargs=dict(kwargs)) + cold = interpolator.c.copy() + + assert interpolator.solve_system("native", solver_kwargs={**kwargs, "warm_start": True}) + assert interpolator.latest_native_report["converged"] + # ADMM stops at a tolerance, so the two fields agree less closely. + assert np.max(np.abs(interpolator.c - cold)) <= 1e-3 * _range(cold) + + +def test_native_ridge_factor_pulls_the_field_to_zero(): + interpolator = _make_interpolator() + assert interpolator.solve_system("native", solver_kwargs={"ridge_factor": 0.0}) + no_ridge = np.linalg.norm(interpolator.c) + assert interpolator.solve_system("native", solver_kwargs={"ridge_factor": 1.0}) + assert np.linalg.norm(interpolator.c) < 0.9 * no_ridge + + +def test_native_ridge_off_is_the_same_as_ridge_factor_zero(): + interpolator = _make_interpolator() + assert interpolator.solve_system("native", solver_kwargs={"ridge_factor": 0.0}) + zero = interpolator.c.copy() + assert interpolator.solve_system( + "native", solver_kwargs={"add_ridge_regularisation": False, "ridge_factor": 1.0} + ) + assert np.allclose(interpolator.c, zero) + + +def test_native_matches_cg_with_the_same_ridge_factor(): + """The core and the matrix cg solver use the same ridge term.""" + native = _make_interpolator() + assert native.solve_system("native", solver_kwargs={"ridge_factor": 0.1}) + + origin = np.array([-0.1, -0.1, -0.1]) + maximum = np.array([1.1, 1.1, 1.1]) + nsteps = np.array([20, 20, 20]) + grid = StructuredGrid(origin=origin, nsteps=nsteps, step_vector=(maximum - origin) / nsteps) + matrix = FDI(grid) + matrix.apply_scaling_matrix = False + rng = np.random.default_rng(0) + xyz = rng.uniform(0.1, 0.9, size=(60, 3)) + values = xyz[:, 2] + 0.1 * np.sin(2.0 * xyz[:, 0]) * xyz[:, 1] + matrix.set_value_constraints(np.column_stack([xyz, values, np.ones(60)])) + matrix.setup() + assert matrix.solve_system("cg", solver_kwargs={"ridge_factor": 0.1, "rtol": 1e-10}) + + assert np.max(np.abs(native.c - matrix.c)) <= 1e-5 * _range(matrix.c) diff --git a/packages/loop_interpolation/tests/test_solver_pipeline.py b/packages/loop_interpolation/tests/test_solver_pipeline.py index a6038014..62387985 100644 --- a/packages/loop_interpolation/tests/test_solver_pipeline.py +++ b/packages/loop_interpolation/tests/test_solver_pipeline.py @@ -54,6 +54,45 @@ def fake_scaling(mat): assert "preprocess_seconds" in timing +def test_extract_ridge_options_defaults_and_overrides(): + logger = logging.getLogger("test_solver_pipeline") + + kwargs = {} + assert pipeline.extract_ridge_options(kwargs, True, 1e-8, logger) == (True, 1e-8) + + kwargs = {"ridge_factor": 0.5} + assert pipeline.extract_ridge_options(kwargs, False, 1e-8, logger) == (True, 0.5) + assert kwargs == {} + + kwargs = {"ridge_factor": 0.0} + assert pipeline.extract_ridge_options(kwargs, True, 1e-8, logger)[0] is False + + kwargs = {"add_ridge_regularisation": False} + assert pipeline.extract_ridge_options(kwargs, True, 1e-8, logger)[0] is False + + +def test_extract_initial_guess_priority_and_validation(): + logger = logging.getLogger("test_solver_pipeline") + previous = np.array([1.0, 2.0]) + + assert pipeline.extract_initial_guess({}, previous, None, 2, logger) is None + + x0 = pipeline.extract_initial_guess({"warm_start": True}, previous, None, 2, logger) + assert np.allclose(x0, previous) + # the guess is a copy, so _pre_solve cannot change it + previous[0] = np.nan + assert np.isfinite(x0[0]) + + kwargs = {"warm_start": True, "x0": lambda _support: np.array([3.0, 4.0])} + x0 = pipeline.extract_initial_guess(kwargs, previous, None, 2, logger) + assert np.allclose(x0, [3.0, 4.0]) + assert kwargs == {} + + nan_previous = np.array([np.nan, 1.0]) + assert pipeline.extract_initial_guess({"warm_start": True}, nan_previous, None, 2, logger) is None + assert pipeline.extract_initial_guess({"x0": np.zeros(3)}, previous, None, 2, logger) is None + + def test_assemble_inequality_system_records_timing(): def fake_build_ineq(): return sparse.csr_matrix((3, 2), dtype=float), np.zeros((3, 3), dtype=float) diff --git a/packages/loop_interpolation/tests/test_solver_strategy.py b/packages/loop_interpolation/tests/test_solver_strategy.py index ae7d9758..be8cbace 100644 --- a/packages/loop_interpolation/tests/test_solver_strategy.py +++ b/packages/loop_interpolation/tests/test_solver_strategy.py @@ -11,6 +11,14 @@ def test_resolve_solver_choice_fallbacks_to_cg_for_unknown_name(): assert resolved == "cg" +def test_resolve_solver_choice_accepts_native_without_warning(caplog): + logger = logging.getLogger("test_solver_strategy") + with caplog.at_level(logging.WARNING, logger="test_solver_strategy"): + resolved = strategy.resolve_solver_choice("native", logger) + assert resolved == "native" + assert not [record for record in caplog.records if record.levelno >= logging.WARNING] + + def test_extract_admm_kwargs_filters_to_supported_signature(): def fake_admm_solve( A, @@ -147,6 +155,66 @@ def test_cg_iteration_limit_reports_not_converged(): assert ok is True +def _p1_interpolator(): + from loop_interpolation import P1Interpolator, TetMesh + + mesh = TetMesh(origin=np.zeros(3), nsteps=np.full(3, 4), step_vector=np.ones(3)) + interp = P1Interpolator(mesh) + interp.set_value_constraints(np.array([[1.0, 1.0, 1.0, 0.5, 1.0], [2.0, 2.0, 2.0, 1.0, 1.0]])) + interp.setup_interpolator(regularisation=1.0) + return interp + + +def test_solve_system_ridge_factor_changes_solution(): + interp = _p1_interpolator() + interp.solve_system("lsmr", solver_kwargs={"ridge_factor": 0.0}) + no_ridge = interp.c.copy() + interp.solve_system("lsmr", solver_kwargs={"ridge_factor": 1.0}) + ridge = interp.c.copy() + assert np.all(np.isfinite(ridge)) + # a strong ridge pulls the solution towards zero + assert np.linalg.norm(ridge) < np.linalg.norm(no_ridge) + + +def test_solve_system_warm_start_uses_previous_solution(monkeypatch): + interp = _p1_interpolator() + for solver in ("cg", "lsmr"): + interp.solve_system(solver) + previous = interp.c.copy() + captured = {} + name = "solve_with_cg" if solver == "cg" else "solve_with_lsmr" + original = getattr(strategy, name) + + def spy(*args, x0=None, _original=original, **kwargs): + captured["x0"] = x0 + return _original(*args, x0=x0, **kwargs) + + monkeypatch.setattr(strategy, name, spy) + interp.solve_system(solver, solver_kwargs={"warm_start": True}) + monkeypatch.undo() + + assert captured["x0"] is not None + assert np.all(np.isfinite(captured["x0"])) + assert np.allclose(interp.c, previous, atol=1e-6) + + +def test_solve_system_warm_start_needs_fewer_iterations(): + interp = _p1_interpolator() + interp.apply_scaling_matrix = False + interp.solve_system("cg") + iterations = {"n": 0} + + def count(_xk): + iterations["n"] += 1 + + interp.solve_system("cg", solver_kwargs={"warm_start": True, "callback": count}) + warm = iterations["n"] + iterations["n"] = 0 + interp.solve_system("cg", solver_kwargs={"callback": count}) + cold = iterations["n"] + assert warm < cold + + def test_update_reuses_setup_kwargs(): import numpy as np from loop_interpolation import P1Interpolator, TetMesh diff --git a/packages/loop_interpolation_core/Cargo.lock b/packages/loop_interpolation_core/Cargo.lock new file mode 100644 index 00000000..3aa2d06b --- /dev/null +++ b/packages/loop_interpolation_core/Cargo.lock @@ -0,0 +1,355 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "loop-interpolation-core" +version = "0.1.0" +dependencies = [ + "numpy", + "pyo3", + "rayon", + "wasm-bindgen", +] + +[[package]] +name = "matrixmultiply" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7" +dependencies = [ + "autocfg", + "rawpointer", +] + +[[package]] +name = "ndarray" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d" +dependencies = [ + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "portable-atomic", + "portable-atomic-util", + "rawpointer", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "numpy" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a5b15d63a5ff39e378daed0e1340d3a5964703ea9712eb09a0dc66fade996f4" +dependencies = [ + "libc", + "ndarray", + "num-complex", + "num-integer", + "num-traits", + "pyo3", + "pyo3-build-config", + "rustc-hash", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pyo3" +version = "0.29.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "700d18fa267b73b9b521fd7e13580e2f446916f176cacee1ab63fcc8191f1655" +dependencies = [ + "libc", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", +] + +[[package]] +name = "pyo3-build-config" +version = "0.29.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b3fc0c4d08f6bb10e71fe39dfb9e2f59c6eb6854e22ec8092f50c69a4499adb" +dependencies = [ + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.29.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfc0b8e19df29aad7086cf977bb0c2a2f143e30567eb113e9cf72b62ca698330" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.29.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6100e8a4b5eba53afaa5ed078364851a0b2499c553a44c31026b929049b49dc6" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.29.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6143877a16e82b5a727b7127ff4cd86858a24a28d745d72f43e6f227c7b1bdb3" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] diff --git a/packages/loop_interpolation_core/Cargo.toml b/packages/loop_interpolation_core/Cargo.toml new file mode 100644 index 00000000..9e18ea4b --- /dev/null +++ b/packages/loop_interpolation_core/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "loop-interpolation-core" +version = "0.1.0" +edition = "2024" + +[lib] +name = "loop_interpolation_core" +crate-type = ["cdylib", "rlib"] + +[dependencies] +rayon = { version = "1.10", optional = true } +pyo3 = { version = "0.29", features = ["extension-module", "abi3-py39"], optional = true } +numpy = { version = "0.29", optional = true } +wasm-bindgen = { version = "0.2", optional = true } + +[features] +default = [] +parallel = ["dep:rayon"] +python = ["dep:pyo3", "dep:numpy"] +wasm = ["dep:wasm-bindgen"] + +[profile.wasm-size] +inherits = "release" +opt-level = "z" +lto = true +codegen-units = 1 +panic = "abort" diff --git a/packages/loop_interpolation_core/README.md b/packages/loop_interpolation_core/README.md new file mode 100644 index 00000000..ac3df2b0 --- /dev/null +++ b/packages/loop_interpolation_core/README.md @@ -0,0 +1,136 @@ +# loop-interpolation-core + +## Purpose + +This package is the native core of the low-memory interpolator. It is a +Python extension module, built with maturin. It is named +`loop_interpolation_core` on import. The package solves the finite +difference interpolation problem without building the sparse constraint +rows in Python. + +The package is optional. The `loop-interpolation` package works without it. + +## Specification + +The requirements and the design are in +[specs/001-low-memory-interpolator/spec.md](../../specs/001-low-memory-interpolator/spec.md). + +## Forms + +The crate builds in three forms: + +- Rust library (rlib). Use it from other Rust crates. +- Python module (PyO3). It uses the abi3 stable ABI, so one wheel works for + Python 3.9 and later. +- Browser module (wasm-bindgen). It runs in a Web Worker. The worker code is + in `web/`. + +## Feature flags + +| Feature | Effect | +|---|---| +| `parallel` | Uses rayon threads. Off for cargo by default. On for the Python wheel. | +| `python` | Builds the PyO3 bindings. | +| `wasm` | Builds the wasm-bindgen bindings. | + +With no feature, the code runs on one thread and has no dependency. + +## Build commands + +### Python module + +```text +pip install maturin +maturin develop --release -m packages/loop_interpolation_core/Cargo.toml +``` + +The `pyproject.toml` sets the features `python` and `parallel`. If you pass +`--features`, you must list both: `--features python,parallel`. The option +`--features parallel` alone replaces the list. The build then fails with an +"install cffi" message. + +### Browser module + +```text +bash packages/loop_interpolation_core/scripts/build_wasm.sh +``` + +You need these tools: + +- The `wasm32-unknown-unknown` Rust target. +- A `wasm-bindgen` CLI with the same version as the `wasm-bindgen` crate in + `Cargo.lock`. The script stops if the versions differ. +- `wasm-opt` is optional. If it is missing, the script skips this step. + +The script writes the output to `packages/loop_interpolation_core/pkg/`. + +### Tests and lint + +Run these commands from `packages/loop_interpolation_core`: + +```text +cargo test --release +cargo test --release --features parallel + +cargo clippy --all-targets -- -D warnings +cargo clippy --all-targets --features python -- -D warnings +cargo clippy --features wasm --target wasm32-unknown-unknown -- -D warnings +``` + +To run the browser tests: + +```text +npm --prefix packages/loop_interpolation_core/tests_web ci +npm --prefix packages/loop_interpolation_core/tests_web test +``` + +## Contract + +- Inputs are flat arrays. Points are stored as 3n values, one x, y, z set + for each point. +- The grid uses grid coordinates only. The grid has no rotation. The caller + transforms the coordinates. +- Node order is x fastest. +- Errors name the constraint that failed. For example, `value[3]`. +- A solve is a session. You step the session to advance the solve. To cancel + it, drop the session. +- The memory estimate is available before the solve. +- Set `budget_bytes` to limit the memory of a solve. + +## Differences from loop-rs + +- Grid rotation is removed. The functions `rotation`, `to_local` and + `direction_to_local` are not in this crate. The `serde` feature is also + removed. +- Solve sessions replace the closure-style solvers. A session has step and + cancel. +- Memory tracking is new. The crate uses `try_reserve`, a byte budget and a + memory estimate. +- Curvature rows are added only at nodes that are interior on all three + axes. The border rows are centred second derivatives `[1, -2, 1]` on + layers 1 and n-2. The gradient strike and dip basis is the Python + strike and dip basis. So the field equals the field of the Python matrix + solver. +- Only the ADMM solver is in this crate. There is no active-set solver and + no hybrid solver. A feasibility check runs before the solve. The solver + stops when the violation is at most 1e-4 of the field range. +- Single precision (`f32`) is available. The residual check runs in double + precision. + +## Measured values + +- Memory: 63 bytes per node for R1, a 100 x 100 x 100 grid with 2000 value + rows and 500 gradient rows. The value is the peak heap size divided by + the number of nodes. It does not include the copy of the field that goes + to JavaScript, which takes 8 bytes per node. +- Comparison with the Python matrix solver on a 100 x 100 x 100 grid: the + native solver uses 84 MB where the matrix solver uses 2445 MB (29 times + less). On one thread it is 10 to 30 percent slower. With the `parallel` + feature (the default of the Python wheel) it is about 3 times faster. The + numbers are in `specs/001-low-memory-interpolator/measurements.md`. +- Browser size: the gzip size of the `.wasm` file is about 92 KB. The + limit is 2 MB. + +## Source + +Source: loop-rs crates/interpolation-fd, commit bff51342da8ebff7ca3c87e07c1d4fbd31279c8b, branch lachlan/drillhole diff --git a/packages/loop_interpolation_core/fixtures/fixture_20.bin b/packages/loop_interpolation_core/fixtures/fixture_20.bin new file mode 100644 index 00000000..d93a065d Binary files /dev/null and b/packages/loop_interpolation_core/fixtures/fixture_20.bin differ diff --git a/packages/loop_interpolation_core/fixtures/make_fixture.py b/packages/loop_interpolation_core/fixtures/make_fixture.py new file mode 100644 index 00000000..64e149c1 --- /dev/null +++ b/packages/loop_interpolation_core/fixtures/make_fixture.py @@ -0,0 +1,289 @@ +"""Make and check the cross-build fixture ``fixture_20.bin``. + +Use: + python -I make_fixture.py write fixture_20.bin + python -I make_fixture.py --check read fixture_20.bin, solve again with the + Python matrix solver, compare + +The problem is a 20 x 20 x 20 ``FiniteDifferenceInterpolator`` problem with +value, gradient, normal, tangent and interface data (fixed seed 20240607). +The expected field is the reference solution: the matrix solver with +``solver="cg"`` and a tight tolerance (rtol = atol = 1e-10). + +File format (little endian). Bytes 0 to 3 are the ASCII text ``LIF1``. Then a +list of blocks. Each block is a ``u32`` length N and N values of ``f64``. +Block order: + + origin(3) counts(3) step(3) reg(6: dxx dyy dzz dxy dxz dyz) + border(3: dx dy dz) ridge(1) + value_points value_values value_weights + gradient_points gradient_vectors gradient_weights + normal_points normal_vectors normal_weights + tangent_points tangent_vectors tangent_weights + interface_points interface_ids interface_weight(1) + expected_field(N = product of counts) + +Points and vectors are flat arrays (3 values for each point). The node order +of ``expected_field`` is the node order of ``interpolator.c`` of the matrix +solver (x is the fastest index). + +MAPPING from the Python data columns to the core meaning +-------------------------------------------------------- +All weights in the file are in core meaning: the core applies the weight +verbatim to a row that it has already scaled to unit L2 norm. The Python +matrix solver (``loop_interpolation.FiniteDifferenceInterpolator``) gives +these equivalents. Python column layouts are x y z | data | weight. + +* Grid. ``counts`` is the number of NODES for each axis. The Python grid is + ``StructuredGrid(origin, nsteps_cells=counts - 1, step_vector=step)``. The + node count is ``counts``. No rotation. +* Regularisation. ``dxx dyy dzz`` are the same in Python and core. The + cross-derivative weights are divided by 4 inside + ``StructuredGrid.get_operators``. The file holds the core meaning, so + ``dxy_file = dxy_python / 4`` (and the same for ``dxz``, ``dyz``). Python + setup uses ``dxy = 4 * dxy_file``. +* Border. ``dx dy dz`` are the same in Python and core. The Python border rows + are the second-derivative rows on the node layer 1 and layer n - 2 of each + axis (``assemble_borders``). +* Ridge. ``ridge`` is ``ridge_factor`` (Python default 1e-8). It adds + ``ridge**2 * I`` to the normal equations. +* Value. Python row weight = ``cpw * column_weight`` (column 4). File weight = + ``cpw * column_weight``. The generator uses ``cpw = 1``, so the file weight + is the column weight. +* Gradient (direction only, unit vector, two rows for each point: strike and + dip). Python row weight = ``gpw``. The weight column is NOT used by Python. + File weight = ``gpw``. All gradient weights in the file are equal. +* Normal (vector with length, three rows for each point, one for each axis). + Python row weight = ``npw / 3 * column_weight`` (column 6). File weight = + ``npw / 3 * column_weight``. The generator uses ``npw = 3``, so the file + weight is the column weight. The vector is NOT made a unit vector. +* Tangent (``grad . t = 0``, vector made unit length, one row for each point). + Python row weight = ``tpw``. The weight column is NOT used by Python. File + weight = ``tpw``. All tangent weights in the file are equal. +* Interface (equal value for all points with one id). Python row weight = + ``ipw``. Python must use ``interface_pair_mode = "star"``: each point is + paired with the first point of its id group. This is the pairing of the core. + File weight = ``ipw`` (one value). +* Rows outside the grid are dropped. The generator puts all data inside the + grid. +* Solver. ``solve_system("cg", solver_kwargs={"rtol": 1e-10, "atol": 1e-10, + "maxiter": 50000})``. The field is ``interpolator.c``. +""" + +from __future__ import annotations + +import struct +import sys +from pathlib import Path + +import numpy as np + +HERE = Path(__file__).resolve().parent +FIXTURE = HERE / "fixture_20.bin" +MAGIC = b"LIF1" +SEED = 20240607 + +# Put the packages of this repository first on the import path. This also works +# with ``python -I``, which ignores PYTHONPATH. +for _src in ( + HERE.parents[1] / "loop_common" / "src", + HERE.parents[1] / "loop_interpolation" / "src", +): + if _src.is_dir(): + sys.path.insert(0, str(_src)) + +CG_KWARGS = {"rtol": 1e-10, "atol": 1e-10, "maxiter": 50000} + +ORIGIN = np.array([10.0, -5.0, 3.0]) +COUNTS = np.array([20, 20, 20]) +STEP = np.array([1.0, 1.0, 1.0]) +# dxx dyy dzz dxy dxz dyz in core meaning (cross terms already divided by 4) +REG = np.array([1.0, 1.0, 1.0, 0.25, 0.25, 0.25]) +BORDER = np.array([1.0, 1.0, 1.0]) +RIDGE = 1e-8 +GRADIENT_WEIGHT = 1.0 +TANGENT_WEIGHT = 1.0 +INTERFACE_WEIGHT = 1.0 + +BLOCK_NAMES = ( + "origin counts step reg border ridge " + "value_points value_values value_weights " + "gradient_points gradient_vectors gradient_weights " + "normal_points normal_vectors normal_weights " + "tangent_points tangent_vectors tangent_weights " + "interface_points interface_ids interface_weight " + "expected_field" +).split() + + +def _field(p): + """Smooth scalar field that makes the test data.""" + x = (p[:, 0] - ORIGIN[0]) / 19.0 + y = (p[:, 1] - ORIGIN[1]) / 19.0 + z = (p[:, 2] - ORIGIN[2]) / 19.0 + return 3.0 * x + 1.5 * np.sin(2.0 * y) + 2.0 * z * z + 0.5 * x * y + + +def _field_gradient(p): + x = (p[:, 0] - ORIGIN[0]) / 19.0 + y = (p[:, 1] - ORIGIN[1]) / 19.0 + z = (p[:, 2] - ORIGIN[2]) / 19.0 + gx = 3.0 + 0.5 * y + gy = 3.0 * np.cos(2.0 * y) + 0.5 * x + gz = 4.0 * z + return np.column_stack([gx, gy, gz]) / 19.0 + + +def make_inputs() -> dict: + """Make the input arrays (no expected field). All data is inside the grid.""" + rng = np.random.default_rng(SEED) + lo = ORIGIN + 1.5 * STEP + hi = ORIGIN + (COUNTS - 1 - 1.5) * STEP + + def pts(n): + return rng.uniform(lo, hi, size=(n, 3)) + + vp = pts(60) + gp = pts(20) + npts = pts(15) + tp = pts(15) + ip = pts(12) + g_dir = _field_gradient(gp) + g_dir = g_dir / np.linalg.norm(g_dir, axis=1)[:, None] + n_vec = _field_gradient(npts) + t_grad = _field_gradient(tp) + # tangent vector: a unit vector that is orthogonal to the field gradient + helper = np.tile([0.0, 0.0, 1.0], (len(tp), 1)) + t_vec = np.cross(t_grad, helper) + t_vec /= np.linalg.norm(t_vec, axis=1)[:, None] + ids = np.repeat(np.arange(4, dtype=float), 3) + return { + "origin": ORIGIN, + "counts": COUNTS.astype(float), + "step": STEP, + "reg": REG, + "border": BORDER, + "ridge": np.array([RIDGE]), + "value_points": vp.ravel(), + "value_values": _field(vp), + "value_weights": rng.uniform(0.5, 2.0, len(vp)), + "gradient_points": gp.ravel(), + "gradient_vectors": g_dir.ravel(), + "gradient_weights": np.full(len(gp), GRADIENT_WEIGHT), + "normal_points": npts.ravel(), + "normal_vectors": n_vec.ravel(), + "normal_weights": rng.uniform(0.5, 2.0, len(npts)), + "tangent_points": tp.ravel(), + "tangent_vectors": t_vec.ravel(), + "tangent_weights": np.full(len(tp), TANGENT_WEIGHT), + "interface_points": ip.ravel(), + "interface_ids": ids, + "interface_weight": np.array([INTERFACE_WEIGHT]), + } + + +def solve_reference(d: dict) -> np.ndarray: + """Solve the problem of ``d`` with the Python matrix solver (cg).""" + from loop_common.supports import StructuredGrid # noqa: PLC0415 + from loop_interpolation import FiniteDifferenceInterpolator # noqa: PLC0415 + + counts = d["counts"].astype(int) + grid = StructuredGrid(origin=d["origin"], nsteps_cells=counts - 1, step_vector=d["step"]) + assert tuple(np.asarray(grid.nsteps).astype(int)) == tuple(counts), grid.nsteps + interp = FiniteDifferenceInterpolator(grid) + + def rows(name, k): + return np.asarray(d[name], dtype=float).reshape(-1, k) + + interp.set_value_constraints( + np.column_stack([rows("value_points", 3), d["value_values"], d["value_weights"]]) + ) + gw = d["gradient_weights"] + assert np.all(gw == gw[0]), "Python ignores the gradient weight column" + interp.set_gradient_constraints( + np.column_stack([rows("gradient_points", 3), rows("gradient_vectors", 3), gw]) + ) + interp.set_normal_constraints( + np.column_stack([rows("normal_points", 3), rows("normal_vectors", 3), d["normal_weights"]]) + ) + tw = d["tangent_weights"] + assert np.all(tw == tw[0]), "Python ignores the tangent weight column" + interp.set_tangent_constraints( + np.column_stack([rows("tangent_points", 3), rows("tangent_vectors", 3), tw]) + ) + interp.set_interface_constraints( + np.column_stack([rows("interface_points", 3), d["interface_ids"]]) + ) + interp.ridge_factor = float(d["ridge"][0]) + r = d["reg"] + b = d["border"] + interp.setup_interpolator( + dxx=r[0], + dyy=r[1], + dzz=r[2], + dxy=4.0 * r[3], + dxz=4.0 * r[4], + dyz=4.0 * r[5], + dx=b[0], + dy=b[1], + dz=b[2], + cpw=1.0, + gpw=float(gw[0]), + npw=3.0, + tpw=float(tw[0]), + ipw=float(d["interface_weight"][0]), + interface_pair_mode="star", + ) + ok = interp.solve_system("cg", solver_kwargs=dict(CG_KWARGS)) + if not ok: + raise RuntimeError("the Python cg solve did not converge") + return np.asarray(interp.c, dtype=float).copy() + + +def write(path: Path, d: dict, expected: np.ndarray) -> None: + with open(path, "wb") as f: + f.write(MAGIC) + for name in BLOCK_NAMES: + a = expected if name == "expected_field" else d[name] + a = np.ascontiguousarray(a, dtype=" dict: + raw = path.read_bytes() + if raw[:4] != MAGIC: + raise ValueError("bad magic") + pos = 4 + out = {} + for name in BLOCK_NAMES: + (n,) = struct.unpack_from(" int: + if "--check" in argv: + d = read(FIXTURE) + n = int(np.prod(d["counts"])) + if d["expected_field"].size != n: + print("expected_field has the wrong size") + return 1 + field = solve_reference(d) + span = float(np.ptp(d["expected_field"])) + diff = float(np.max(np.abs(field - d["expected_field"]))) + print(f"max difference {diff:.3e}, field range {span:.3e}, ratio {diff / span:.3e}") + return 0 if diff <= 1e-6 * span else 1 + d = make_inputs() + expected = solve_reference(d) + write(FIXTURE, d, expected) + print(f"wrote {FIXTURE} ({FIXTURE.stat().st_size} bytes)") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/packages/loop_interpolation_core/pyproject.toml b/packages/loop_interpolation_core/pyproject.toml new file mode 100644 index 00000000..4d27277b --- /dev/null +++ b/packages/loop_interpolation_core/pyproject.toml @@ -0,0 +1,33 @@ +[build-system] +requires = ["maturin>=1,<2"] +build-backend = "maturin" + +[project] +name = "loop-interpolation-core" +version = "0.1.0" +description = "Native (Rust) core for the low-memory LoopStructural interpolator" +readme = "README.md" +requires-python = ">=3.9" +dependencies = ["numpy"] +authors = [{ name = "Lachlan Grose", email = "lachlan.grose@monash.edu" }] +license = { text = "MIT" } +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Science/Research", + "Topic :: Scientific/Engineering :: Information Analysis", + "License :: OSI Approved :: MIT License", + "Operating System :: Microsoft :: Windows", + "Operating System :: POSIX :: Linux", + "Operating System :: MacOS", + "Programming Language :: Python :: 3", + "Programming Language :: Rust", +] + +[project.urls] +Documentation = "https://Loop3d.org/LoopStructural/" +"Bug Tracker" = "https://github.com/loop3d/loopstructural/issues" +"Source Code" = "https://github.com/loop3d/loopstructural" + +[tool.maturin] +features = ["python", "parallel"] +module-name = "loop_interpolation_core" diff --git a/packages/loop_interpolation_core/scripts/build_wasm.sh b/packages/loop_interpolation_core/scripts/build_wasm.sh new file mode 100644 index 00000000..3c20b5bb --- /dev/null +++ b/packages/loop_interpolation_core/scripts/build_wasm.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# Build the browser package (wasm + JS glue) into pkg/. +# Steps: cargo build, wasm-bindgen, wasm-opt (optional). +set -euo pipefail + +# Work from the package root, whatever the current directory is. +cd "$(dirname "$0")/.." + +NAME=loop_interpolation_core + +# Step 1: build the wasm with the size profile. +cargo build --profile wasm-size --target wasm32-unknown-unknown --features wasm + +# Step 2: find the cargo target dir. The user config can set a shared dir. +# Use the first of python3, python that works. +PY=python +command -v python3 >/dev/null 2>&1 && python3 -c "" >/dev/null 2>&1 && PY=python3 +TARGET_DIR="$(cargo metadata --format-version 1 --no-deps \ + | "$PY" -c 'import json,sys; print(json.load(sys.stdin)["target_directory"])')" +# Git Bash on Windows: change a Windows path to a POSIX path if cygpath exists. +if command -v cygpath >/dev/null 2>&1; then + TARGET_DIR="$(cygpath -u "$TARGET_DIR")" +fi +WASM="$TARGET_DIR/wasm32-unknown-unknown/wasm-size/$NAME.wasm" +if [ ! -f "$WASM" ]; then + echo "error: wasm file not found: $WASM" >&2 + exit 1 +fi + +# Step 3: the wasm-bindgen CLI version must equal the crate version in Cargo.lock. +CRATE_VER="$(awk '/^name = "wasm-bindgen"$/ {getline; gsub(/[^0-9.]/, "", $0); print; exit}' Cargo.lock)" +CLI_VER="$(wasm-bindgen --version | awk '{print $2}')" +if [ "$CRATE_VER" != "$CLI_VER" ]; then + echo "error: wasm-bindgen CLI is $CLI_VER but Cargo.lock has $CRATE_VER." >&2 + echo "fix: cargo install wasm-bindgen-cli --version $CRATE_VER --locked" >&2 + exit 1 +fi + +# Step 4: make the JS glue and the final wasm. +rm -rf pkg +wasm-bindgen --target web --out-dir pkg --out-name "$NAME" "$WASM" + +# Step 5: shrink the wasm in place, if wasm-opt is present. +if command -v wasm-opt >/dev/null 2>&1; then + wasm-opt -Oz -o "pkg/${NAME}_bg.wasm" "pkg/${NAME}_bg.wasm" +else + echo "wasm-opt not found: skipped" +fi + +echo "Built pkg/ in $(pwd)/pkg" diff --git a/packages/loop_interpolation_core/scripts/check_wasm_size.py b/packages/loop_interpolation_core/scripts/check_wasm_size.py new file mode 100644 index 00000000..4178a1ab --- /dev/null +++ b/packages/loop_interpolation_core/scripts/check_wasm_size.py @@ -0,0 +1,32 @@ +"""Check the gzip size of the browser wasm (SC-010). + +Run with: python -I packages/loop_interpolation_core/scripts/check_wasm_size.py +Exit code 1 if the gzip size is above the limit. +""" +import gzip +import pathlib +import sys + +LIMIT = 2_000_000 # bytes, gzip level 9 (SC-010) + + +def main() -> int: + # Find pkg/ from the script file, not from the current directory. + pkg = pathlib.Path(__file__).resolve().parent.parent / "pkg" + files = sorted(pkg.glob("*_bg.wasm")) + if not files: + print(f"error: no *_bg.wasm in {pkg}: run build_wasm.sh first", file=sys.stderr) + return 2 + status = 0 + for path in files: + raw = path.read_bytes() + packed = gzip.compress(raw, compresslevel=9) + print(f"{path.name}: raw {len(raw)} bytes, gzip {len(packed)} bytes") + if len(packed) > LIMIT: + print("Over the limit: see OP2 in plan.md") + status = 1 + return status + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/packages/loop_interpolation_core/src/admm.rs b/packages/loop_interpolation_core/src/admm.rs new file mode 100644 index 00000000..fa117dc6 --- /dev/null +++ b/packages/loop_interpolation_core/src/admm.rs @@ -0,0 +1,579 @@ +//! ADMM solver state machine for inequality bounds. +//! +//! The method is the one of `FiniteDifferenceInterpolator::solve_admm`. +//! Outer loop: `z = clip(Q x + u, lower, upper)`, then `u += Q x - z`. +//! Inner loop: Jacobi CG on `(N + rho Q^T Q) x = rhs + rho Q^T (z - u)`. +//! +//! Full-size vectors: `x`, `r`, `p`, `ap` and `inv_diag`. +//! ADMM adds no full-size vector to plain CG. The data right-hand side is +//! computed again from the constraint rows when `r` is rebuilt. +//! `r` is rebuilt at the start of each outer iteration. No dense matrix is made. +//! Vectors with one entry per inequality row: `z`, `u`, `lower`, `upper`. +//! The row order is: value-bound rows, then pair-bound rows. + +use crate::PAR_MIN_LEN; +use crate::cg::{dot_s, is_single, jacobi_inverse, weighted_dot}; +use crate::error::{BoundKind, CoreError, ViolatedConstraint}; +use crate::interpolator::FiniteDifferenceInterpolator; +use crate::memory::MemTracker; +use crate::par::*; +use crate::scalar::Scalar; +use crate::settings::Settings; +use std::any::Any; + +/// Number of outer iterations without a 1 percent fall of the primal residual. +const STALL_OUTER: usize = 20; +/// Number of violated rows in an `Infeasible` error. +const MAX_WORST: usize = 20; +/// Largest allowed violation of a bound, as a fraction of the field range +/// (max(x) - min(x)). This is SC-004 of spec 001-low-memory-interpolator: +/// every inequality constraint holds with a violation of at most 1e-4 of the +/// field range. The violation is measured in field units. +pub(crate) const VIOLATION_LIMIT: f64 = 1e-4; + +/// Result of one `AdmmState::step` call. +#[derive(Debug, Clone, Copy)] +pub(crate) enum AdmmOutcome { + /// The solve goes on. `check` is true if an outer iteration just ended. + Running { + check: bool, + iteration: usize, + residual: f64, + max_violation: f64, + }, + /// The solve has ended. The value is true if it converged. + Done(bool), +} + +#[derive(Debug, Clone, Copy, PartialEq)] +enum Phase { + /// The next call must start the pre-solve. + Start, + /// The plain CG pre-solve of `N x = rhs` runs. + Pre, + /// The next call must start an outer iteration. + Begin, + /// The inner CG runs. + Inner, +} + +/// ADMM as a state machine. +#[derive(Debug)] +pub(crate) struct AdmmState { + pub x: Vec, + r: Vec, + p: Vec, + ap: Vec, + inv_diag: Vec, + z: Vec, + u: Vec, + lower: Vec, + upper: Vec, + /// Per row: factor from row units to field units (`1 / scale`). + inv_scale: Vec, + /// Tolerance of the pre-solve (`Settings.tol`). + tol: f64, + /// Tolerance of the CG run that is active now. + cur_tol: f64, + /// Number of value-bound rows (the first rows). + n_value: usize, + rho: f64, + inner_tol: f64, + inner_max_iter: usize, + abs_tol: f64, + rel_tol: f64, + /// Limit of the total inner CG iterations. + max_total: usize, + total_iters: usize, + outer_iters: usize, + // Inner CG state. + phase: Phase, + rz: f64, + rhs_norm: f64, + inner_res: f64, + inner_iters: usize, + breakdown: bool, + // Outer state. + primal_rel: f64, + max_violation: f64, + best_primal: f64, + stall: usize, + done: Option, + /// True for single precision: NaN and stagnation tests are on. + single: bool, + /// True after `restart_from`: the pre-solve starts from `x`, not from 0. + warm: bool, +} + +/// `out = (N + rho Q^T Q) v`. +fn apply( + interp: &FiniteDifferenceInterpolator, + scratch: &mut [S], + rho: f64, + v: &[S], + out: &mut [S], +) { + interp.apply_normal_operator_into(v, scratch, out); + interp.accumulate_inequality_normal(v, rho, out); +} + +/// How far `q` is outside `[lower, upper]`. +#[inline] +fn violation(q: f64, lower: f64, upper: f64) -> f64 { + (lower - q).max(q - upper).max(0.0) +} + +impl AdmmState { + /// Make the state. All large vectors come from `tracker`. + pub(crate) fn new( + tracker: &mut MemTracker, + interp: &FiniteDifferenceInterpolator, + s: &Settings, + ) -> Result { + let n = interp.dof(); + let m = interp.n_inequality_constraints(); + // The diagonal is the one of N only. The pre-solve uses it. The + // inequality part is added when the ADMM loop starts. + let diag = interp.try_diagonal(tracker)?; + // Jacobi: a diagonal value of zero or less gives no scaling. + // For f32 the f64 diagonal is freed before the other vectors are made. + let inv_diag: Vec = jacobi_inverse(tracker, diag)?; + let x: Vec = tracker.try_zeroed(n)?; + let r: Vec = tracker.try_zeroed(n)?; + let p: Vec = tracker.try_zeroed(n)?; + let ap: Vec = tracker.try_zeroed(n)?; + let z: Vec = tracker.try_zeroed(m)?; + let u: Vec = tracker.try_zeroed(m)?; + let (lower, upper) = interp.inequality_bounds(); + Ok(Self { + x, + r, + p, + ap, + inv_diag, + z, + u, + lower, + upper, + inv_scale: interp.inequality_inverse_scales(), + tol: s.tol, + cur_tol: s.tol, + n_value: interp.n_value_inequality_constraints(), + rho: s.rho, + // The inner solve is at least as tight as the global tolerance. + inner_tol: s.inner_tol.min(s.tol), + inner_max_iter: s.inner_max_iter, + abs_tol: s.admm_abs_tol, + rel_tol: s.admm_rel_tol, + max_total: s.max_iter, + total_iters: 0, + outer_iters: 0, + phase: Phase::Start, + rz: 0.0, + rhs_norm: 1e-300, + inner_res: 1.0, + inner_iters: 0, + breakdown: false, + primal_rel: 1.0, + max_violation: 0.0, + best_primal: f64::INFINITY, + stall: 0, + done: None, + single: is_single::(), + warm: false, + }) + } + + /// Free `r`, `p`, `ap` and `inv_diag`. Call it only when the run is done. + pub(crate) fn release_work(&mut self, tracker: &mut MemTracker) { + let item = std::mem::size_of::() as u64; + let count = self.r.len() + self.p.len() + self.ap.len() + self.inv_diag.len(); + self.r = Vec::new(); + self.p = Vec::new(); + self.ap = Vec::new(); + self.inv_diag = Vec::new(); + tracker.release((count as u64).saturating_mul(item)); + } + + /// Total inner CG iterations done. + pub(crate) fn iterations(&self) -> usize { + self.total_iters + } + + /// Relative primal residual of the last outer iteration. + pub(crate) fn residual(&self) -> f64 { + self.primal_rel + } + + /// Largest violation of the bounds at the last outer iteration end. + pub(crate) fn max_violation(&self) -> f64 { + self.max_violation + } + + /// Start an outer iteration: build `r = rhs_admm - A x` and the first `p`. + fn begin_outer(&mut self, interp: &FiniteDifferenceInterpolator, scratch: &mut [S]) { + let m = self.z.len(); + // `zu` is small: one entry per inequality row. + let zu: Vec = (0..m).map(|i| self.z[i] - self.u[i]).collect(); + // Data right-hand side, computed again into `r`. + interp.write_data_rhs(&mut self.r); + interp.accumulate_inequality_transpose(&zu, self.rho, &mut self.r); + // The inner tolerance is relative to the norm of the ADMM right-hand side. + self.rhs_norm = dot_s(&self.r, &self.r).sqrt().max(1e-300); + apply(interp, scratch, self.rho, &self.x, &mut self.ap); + self.r + .par_iter_mut() + .zip(self.ap.par_iter()) + .with_min_len(PAR_MIN_LEN) + .for_each(|(ri, a)| *ri = S::from_f64(ri.to_f64() - a.to_f64())); + self.p + .par_iter_mut() + .zip(self.r.par_iter()) + .zip(self.inv_diag.par_iter()) + .with_min_len(PAR_MIN_LEN) + .for_each(|((pi, ri), di)| *pi = S::from_f64(ri.to_f64() * di.to_f64())); + self.rz = weighted_dot(&self.r, &self.inv_diag); + self.inner_res = dot_s(&self.r, &self.r).sqrt() / self.rhs_norm; + self.inner_iters = 0; + self.breakdown = false; + self.cur_tol = self.inner_tol; + self.phase = Phase::Inner; + } + + /// Set the start value `x`. Call it before the first `step`. + pub(crate) fn restart_from(&mut self, x0: &[f64]) { + debug_assert_eq!(self.phase, Phase::Start); + self.x + .par_iter_mut() + .zip(x0.par_iter()) + .with_min_len(PAR_MIN_LEN) + .for_each(|(xi, v)| *xi = S::from_f64(*v)); + self.warm = true; + } + + /// Start the pre-solve: plain CG of `N x = rhs` from the start `x`. + /// `r` is the data right-hand side, minus `N x` after a warm start. + /// No new vector is needed. The residual is relative to `|rhs|`. + fn begin_pre(&mut self, interp: &FiniteDifferenceInterpolator, scratch: &mut [S]) { + interp.write_data_rhs(&mut self.r); + let rhs_norm = dot_s(&self.r, &self.r).sqrt().max(1e-300); + if self.warm { + interp.apply_normal_operator_into(&self.x, scratch, &mut self.ap); + self.r + .par_iter_mut() + .zip(self.ap.par_iter()) + .with_min_len(PAR_MIN_LEN) + .for_each(|(ri, a)| *ri = S::from_f64(ri.to_f64() - a.to_f64())); + } + self.p + .par_iter_mut() + .zip(self.r.par_iter()) + .zip(self.inv_diag.par_iter()) + .with_min_len(PAR_MIN_LEN) + .for_each(|((pi, ri), di)| *pi = S::from_f64(ri.to_f64() * di.to_f64())); + self.rz = weighted_dot(&self.r, &self.inv_diag); + let r_norm = dot_s(&self.r, &self.r).sqrt(); + self.rhs_norm = rhs_norm; + self.inner_res = r_norm / self.rhs_norm; + self.inner_iters = 0; + self.breakdown = false; + self.cur_tol = self.tol; + self.phase = Phase::Pre; + } + + /// `max(x) - min(x)`, with a floor so that the limit is never zero. + fn field_range(&self) -> f64 { + let (lo, hi) = self + .x + .iter() + .fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), v| { + let v = v.to_f64(); + (lo.min(v), hi.max(v)) + }); + (hi - lo).max(1e-12) + } + + /// Largest violation of the bounds in field units. + fn field_violation(&self, qx: &[f64]) -> f64 { + let mut m = 0.0f64; + for (i, q) in qx.iter().enumerate() { + m = m.max(violation(*q, self.lower[i], self.upper[i]) * self.inv_scale[i]); + } + m + } + + /// End of the pre-solve. Returns true if the plain solution already + /// holds all bounds. If not, it makes the ADMM start from this `x`. + fn end_pre( + &mut self, + tracker: &mut MemTracker, + interp: &FiniteDifferenceInterpolator, + ) -> Result { + let qx = interp.apply_inequality_operator(&self.x); + self.max_violation = self.field_violation(&qx); + self.primal_rel = self.inner_res; + if self.max_violation <= VIOLATION_LIMIT * self.field_range() { + return Ok(true); + } + // Warm start: z = clip(Q x), u = 0. + for (i, q) in qx.iter().enumerate() { + self.z[i] = q.max(self.lower[i]).min(self.upper[i]); + self.u[i] = 0.0; + } + // Put the inequality part into the Jacobi scaling. + // An entry that was zero or less was stored as 1; it stays a weak + // scaling only. This changes the speed, not the result. + if let Some(d) = (&mut self.inv_diag as &mut dyn Any).downcast_mut::>() { + // Double precision: the change is done in place. + d.par_iter_mut() + .with_min_len(PAR_MIN_LEN) + .for_each(|d| *d = 1.0 / *d); + interp.add_inequality_diagonal(self.rho, d); + d.par_iter_mut() + .with_min_len(PAR_MIN_LEN) + .for_each(|d| *d = if *d > 0.0 { 1.0 / *d } else { 1.0 }); + return Ok(false); + } + // Single precision: `add_inequality_diagonal` needs f64 values. + // Use one short-lived f64 vector, counted by the tracker. + let n = self.inv_diag.len(); + let mut tmp: Vec = tracker.try_zeroed(n)?; + tmp.par_iter_mut() + .zip(self.inv_diag.par_iter()) + .with_min_len(PAR_MIN_LEN) + .for_each(|(t, d)| *t = 1.0 / d.to_f64()); + interp.add_inequality_diagonal(self.rho, &mut tmp); + self.inv_diag + .par_iter_mut() + .zip(tmp.par_iter()) + .with_min_len(PAR_MIN_LEN) + .for_each(|(d, t)| *d = S::from_f64(if *t > 0.0 { 1.0 / *t } else { 1.0 })); + tracker.release((n as u64).saturating_mul(8)); + Ok(false) + } + + /// One inner CG iteration. + fn cg_iteration(&mut self, interp: &FiniteDifferenceInterpolator, scratch: &mut [S]) { + if self.phase == Phase::Pre { + interp.apply_normal_operator_into(&self.p, scratch, &mut self.ap); + } else { + apply(interp, scratch, self.rho, &self.p, &mut self.ap); + } + let p_ap = dot_s(&self.p, &self.ap); + if p_ap.abs() < 1e-300 || (self.single && !p_ap.is_finite()) { + self.breakdown = true; + return; + } + let alpha = self.rz / p_ap; + self.x + .par_iter_mut() + .zip(self.p.par_iter()) + .with_min_len(PAR_MIN_LEN) + .for_each(|(xi, pi)| *xi = S::from_f64(xi.to_f64() + alpha * pi.to_f64())); + self.r + .par_iter_mut() + .zip(self.ap.par_iter()) + .with_min_len(PAR_MIN_LEN) + .for_each(|(ri, api)| *ri = S::from_f64(ri.to_f64() - alpha * api.to_f64())); + self.total_iters += 1; + self.inner_iters += 1; + self.inner_res = dot_s(&self.r, &self.r).sqrt() / self.rhs_norm; + if self.inner_res <= self.cur_tol { + return; + } + let rz_new = weighted_dot(&self.r, &self.inv_diag); + let beta = rz_new / self.rz; + self.p + .par_iter_mut() + .zip(self.r.par_iter()) + .zip(self.inv_diag.par_iter()) + .with_min_len(PAR_MIN_LEN) + .for_each(|((pi, ri), di)| { + *pi = S::from_f64(ri.to_f64() * di.to_f64() + beta * pi.to_f64()) + }); + self.rz = rz_new; + } + + fn inner_finished(&self) -> bool { + let own_limit = self.phase != Phase::Pre && self.inner_iters >= self.inner_max_iter; + self.breakdown + || (self.single && !self.inner_res.is_finite()) + || self.inner_res <= self.cur_tol + || own_limit + || self.total_iters >= self.max_total + } + + /// The `z` and `u` update after the inner solve. Returns true if converged. + fn end_outer(&mut self, interp: &FiniteDifferenceInterpolator) -> Result { + let m = self.z.len(); + let qx = interp.apply_inequality_operator(&self.x); + let mut primal2 = 0.0f64; + let mut dual2 = 0.0f64; + let mut qx2 = 0.0f64; + let mut z2 = 0.0f64; + let mut u2 = 0.0f64; + let mut max_violation = 0.0f64; + for (i, &q) in qx.iter().enumerate().take(m) { + let v = q + self.u[i]; + let z_new = v.max(self.lower[i]).min(self.upper[i]); + dual2 += (z_new - self.z[i]).powi(2); + self.z[i] = z_new; + self.u[i] += q - z_new; + primal2 += (q - z_new).powi(2); + qx2 += q * q; + z2 += z_new * z_new; + u2 += self.u[i] * self.u[i]; + // Field units: undo the row scaling. + max_violation = + max_violation.max(violation(q, self.lower[i], self.upper[i]) * self.inv_scale[i]); + } + self.max_violation = max_violation; + let limit = VIOLATION_LIMIT * self.field_range(); + self.outer_iters += 1; + + let primal_norm = primal2.sqrt(); + let dual_norm = self.rho * dual2.sqrt(); + let qx_norm = qx2.sqrt(); + let z_norm = z2.sqrt(); + let u_norm = self.rho * u2.sqrt(); + let sqrt_m = (m as f64).sqrt(); + let eps_pri = sqrt_m * self.abs_tol + self.rel_tol * qx_norm.max(z_norm); + let eps_dual = sqrt_m * self.abs_tol + self.rel_tol * u_norm; + self.primal_rel = primal_norm / qx_norm.max(z_norm).max(1e-300); + + // Converged only if the bounds also hold to SC-004. + if primal_norm <= eps_pri && dual_norm <= eps_dual && max_violation <= limit { + return Ok(true); + } + + // Stall test: the primal residual must fall by 1 percent in 20 outer iterations. + if primal_norm < self.best_primal * 0.99 { + self.best_primal = primal_norm; + self.stall = 0; + } else { + self.stall += 1; + } + if self.stall >= STALL_OUTER && max_violation > limit { + return Err(CoreError::Infeasible { + worst: self.worst_violations(&qx), + }); + } + Ok(false) + } + + /// The largest violations, largest first. At most 20 entries. + fn worst_violations(&self, qx: &[f64]) -> Vec { + let mut all: Vec = Vec::new(); + for (i, q) in qx.iter().enumerate() { + let v = violation(*q, self.lower[i], self.upper[i]) * self.inv_scale[i]; + if v > 0.0 { + let (kind, index) = if i < self.n_value { + (BoundKind::Value, i) + } else { + (BoundKind::Pair, i - self.n_value) + }; + all.push(ViolatedConstraint { + index, + kind, + violation: v, + }); + } + } + all.sort_by(|a, b| { + b.violation + .partial_cmp(&a.violation) + .unwrap_or(std::cmp::Ordering::Equal) + }); + all.truncate(MAX_WORST); + all + } + + /// Run at most `max_iters` inner CG iterations. The call also returns at + /// the end of each outer iteration. + pub(crate) fn step( + &mut self, + tracker: &mut MemTracker, + interp: &FiniteDifferenceInterpolator, + scratch: &mut [S], + max_iters: usize, + ) -> Result { + if let Some(c) = self.done { + return Ok(AdmmOutcome::Done(c)); + } + if self.phase == Phase::Start { + self.begin_pre(interp, scratch); + } + if self.phase == Phase::Pre { + let mut left = max_iters; + while left > 0 && !self.inner_finished() { + self.cg_iteration(interp, scratch); + left -= 1; + } + if !self.inner_finished() { + return Ok(AdmmOutcome::Running { + check: false, + iteration: self.total_iters, + residual: self.inner_res, + max_violation: self.max_violation, + }); + } + if self.inner_res > self.tol || (self.single && !self.inner_res.is_finite()) { + // The pre-solve did not reach its tolerance. + self.primal_rel = self.inner_res; + self.done = Some(false); + return Ok(AdmmOutcome::Done(false)); + } + if self.end_pre(tracker, interp)? { + self.done = Some(true); + return Ok(AdmmOutcome::Done(true)); + } + self.phase = Phase::Begin; + return Ok(AdmmOutcome::Running { + check: true, + iteration: self.total_iters, + residual: self.primal_rel, + max_violation: self.max_violation, + }); + } + if self.phase == Phase::Begin { + if self.total_iters >= self.max_total || self.outer_iters >= self.max_total { + self.done = Some(false); + return Ok(AdmmOutcome::Done(false)); + } + self.begin_outer(interp, scratch); + } + let mut left = max_iters; + while left > 0 && !self.inner_finished() { + self.cg_iteration(interp, scratch); + left -= 1; + } + if !self.inner_finished() { + return Ok(AdmmOutcome::Running { + check: false, + iteration: self.total_iters, + residual: self.primal_rel, + max_violation: self.max_violation, + }); + } + self.phase = Phase::Begin; + if self.single && !self.inner_res.is_finite() { + // Single precision: a NaN residual ends the solve. + self.done = Some(false); + return Ok(AdmmOutcome::Done(false)); + } + if self.end_outer(interp)? { + self.done = Some(true); + return Ok(AdmmOutcome::Done(true)); + } + if self.total_iters >= self.max_total { + self.done = Some(false); + return Ok(AdmmOutcome::Done(false)); + } + Ok(AdmmOutcome::Running { + check: true, + iteration: self.total_iters, + residual: self.primal_rel, + max_violation: self.max_violation, + }) + } +} diff --git a/packages/loop_interpolation_core/src/cg.rs b/packages/loop_interpolation_core/src/cg.rs new file mode 100644 index 00000000..12b46ddf --- /dev/null +++ b/packages/loop_interpolation_core/src/cg.rs @@ -0,0 +1,487 @@ +//! Minimal matrix-free (preconditioned) conjugate gradient solver. +//! +//! `apply`/`precondition` write their result into a caller-supplied output +//! buffer instead of returning a freshly allocated `Vec`, and the solver +//! itself allocates its five `dof`-length work vectors (`x`, `r`, `z`, `p`, +//! `ap`) exactly once regardless of `max_iter`. For a problem of this shape +//! (many cheap iterations over a modest-sized dense array) allocator churn +//! -- not arithmetic -- is the dominant cost if each iteration allocates +//! fresh vectors, so this is the difference between a solver that's +//! actually fast and one that merely looks matrix-free on paper. + +use crate::error::CoreError; +use crate::memory::MemTracker; +use crate::par::*; +use crate::scalar::Scalar; +use std::any::Any; + +use crate::PAR_MIN_LEN; + +pub struct CgResult { + pub x: Vec, + pub iterations: usize, + pub residual_norm: f64, + pub converged: bool, +} + +#[inline] +fn dot(a: &[f64], b: &[f64]) -> f64 { + a.par_iter() + .zip(b.par_iter()) + .with_min_len(PAR_MIN_LEN) + .map(|(x, y)| x * y) + .sum() +} + +/// Solve `N x = rhs` with plain (unpreconditioned) conjugate gradient, `N` +/// given implicitly by `apply(x, out)` computing `out = N x`. +pub fn conjugate_gradient( + apply: F, + rhs: &[f64], + x0: Option>, + max_iter: usize, + tol: f64, +) -> CgResult +where + F: FnMut(&[f64], &mut [f64]), +{ + conjugate_gradient_preconditioned( + apply, + |r: &[f64], out: &mut [f64]| out.copy_from_slice(r), + rhs, + x0, + max_iter, + tol, + ) +} + +/// Solve `N x = rhs` with preconditioned conjugate gradient: `N` is given +/// implicitly by `apply(x, out)` (`out = N x`), and the preconditioner +/// `M^-1` implicitly by `precondition(r, out)` (`out = M^-1 r`), e.g. a +/// Jacobi/diagonal solve (see `FiniteDifferenceInterpolator::diagonal`). +/// Passing `|r, out| out.copy_from_slice(r)` recovers plain CG. +pub fn conjugate_gradient_preconditioned( + mut apply: F, + mut precondition: P, + rhs: &[f64], + x0: Option>, + max_iter: usize, + tol: f64, +) -> CgResult +where + F: FnMut(&[f64], &mut [f64]), + P: FnMut(&[f64], &mut [f64]), +{ + let n = rhs.len(); + let mut x = x0.unwrap_or_else(|| vec![0.0; n]); + let mut r = vec![0.0; n]; + let mut p = vec![0.0; n]; + // `ap` also holds `z`: `ap` is free between the update of `r` and the + // next operator call, so no fifth work vector is needed. + let mut ap = vec![0.0; n]; + + apply(&x, &mut ap); // ap = N x0 + r.par_iter_mut() + .zip(rhs.par_iter()) + .zip(ap.par_iter()) + .with_min_len(PAR_MIN_LEN) + .for_each(|((ri, b), a)| *ri = b - a); + precondition(&r, &mut ap); // ap is z here + p.copy_from_slice(&ap); + let mut rz_old = dot(&r, &ap); + + let rhs_norm = dot(rhs, rhs).sqrt().max(1e-300); + let mut iterations = 0usize; + let mut converged = dot(&r, &r).sqrt() / rhs_norm <= tol; + + while !converged && iterations < max_iter { + apply(&p, &mut ap); // ap = N p + let p_ap = dot(&p, &ap); + if p_ap.abs() < 1e-300 { + break; + } + let alpha = rz_old / p_ap; + x.par_iter_mut() + .zip(p.par_iter()) + .with_min_len(PAR_MIN_LEN) + .for_each(|(xi, pi)| *xi += alpha * pi); + r.par_iter_mut() + .zip(ap.par_iter()) + .with_min_len(PAR_MIN_LEN) + .for_each(|(ri, api)| *ri -= alpha * api); + + iterations += 1; + let r_norm = dot(&r, &r).sqrt(); + if r_norm / rhs_norm <= tol { + converged = true; + break; + } + precondition(&r, &mut ap); // ap is z here + let rz_new = dot(&r, &ap); + let beta = rz_new / rz_old; + p.par_iter_mut() + .zip(ap.par_iter()) + .with_min_len(PAR_MIN_LEN) + .for_each(|(pi, zi)| *pi = zi + beta * *pi); + rz_old = rz_new; + } + + CgResult { + x, + iterations, + residual_norm: dot(&r, &r).sqrt(), + converged, + } +} + +/// State of a CG run that is done: why it stopped. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum CgStatus { + /// More iterations are possible. `residual` is relative to `|rhs|`. + Running { iteration: usize, residual: f64 }, + /// The relative residual is at or below the tolerance. + Converged, + /// The total iteration limit is reached. + MaxIter, + /// `p.Ap` is zero, or (single precision only) the residual is NaN or + /// does not fall any more. The solve cannot go on. + Breakdown, +} + +/// Below this relative residual the single precision stagnation test runs. +const F32_STAGNATION_LEVEL: f64 = 1e-4; +/// Number of iterations without a 1 percent fall that means stagnation. +const F32_STAGNATION_ITERS: usize = 50; + +/// `true` if the vector element type `S` is `f32`. +#[inline] +pub(crate) fn is_single() -> bool { + std::mem::size_of::() == 4 +} + +/// `sum(a * b)`, added in `f64`. +#[inline] +pub(crate) fn dot_s(a: &[S], b: &[S]) -> f64 { + a.par_iter() + .zip(b.par_iter()) + .with_min_len(PAR_MIN_LEN) + .map(|(x, y)| x.to_f64() * y.to_f64()) + .sum::() +} + +/// Make the Jacobi factors `1 / diag` of type `S`. A diagonal value of zero +/// or less gives the factor 1 (no scaling). For `S = f64` the input vector is +/// changed in place and returned (no copy). For `S = f32` the function makes +/// a new `f32` vector from `tracker` and gives the `f64` bytes back. +pub(crate) fn jacobi_inverse( + tracker: &mut MemTracker, + mut diag: Vec, +) -> Result, CoreError> { + diag.par_iter_mut() + .with_min_len(PAR_MIN_LEN) + .for_each(|d| *d = if *d > 0.0 { 1.0 / *d } else { 1.0 }); + let boxed: Box = Box::new(diag); + match boxed.downcast::>() { + // `S` is `f64`: same type, no copy. + Ok(same) => Ok(*same), + Err(other) => { + let diag: Vec = match other.downcast::>() { + Ok(v) => *v, + // Not reachable: the box holds a `Vec`. + Err(_) => Vec::new(), + }; + let mut out: Vec = tracker.try_zeroed(diag.len())?; + out.par_iter_mut() + .zip(diag.par_iter()) + .with_min_len(PAR_MIN_LEN) + .for_each(|(o, d)| *o = S::from_f64(*d)); + tracker.release((diag.len() as u64).saturating_mul(8)); + Ok(out) + } + } +} + +/// Preconditioned CG (Jacobi) as a state machine. +/// +/// Full vectors: `x`, `r`, `p`, `ap` and `inv_diag` (5 x size of `S` per dof). +/// There is no `z` and no `rhs`: `r` starts as `rhs` (the start value `x0` +/// is zero) and `rz = sum(r * r * inv_diag)`. Every sum adds in `f64`. +/// [`restart_from`](Self::restart_from) sets a start value that is not zero. +#[derive(Debug)] +pub struct CgState { + pub x: Vec, + pub r: Vec, + pub p: Vec, + pub ap: Vec, + pub inv_diag: Vec, + rz: f64, + rhs_norm: f64, + residual: f64, + tol: f64, + max_iter: usize, + iterations: usize, + done: Option, + /// Best residual seen below the stagnation level (single precision only). + best: f64, + /// Iterations since `best` fell by 1 percent (single precision only). + since_best: usize, + /// Why a single precision run stopped early. + reason: Option<&'static str>, +} + +impl CgState { + /// Make the state. `rhs` becomes `r` (no copy). `diag` is turned into + /// `inv_diag`. `x`, `p` and `ap` come from `tracker`. + pub fn new( + tracker: &mut MemTracker, + rhs: Vec, + diag: Vec, + tol: f64, + max_iter: usize, + ) -> Result { + let n = rhs.len(); + // Do this first: for `f32` the `f64` diagonal is freed before the + // other vectors are made. + let inv_diag: Vec = jacobi_inverse(tracker, diag)?; + let x: Vec = tracker.try_zeroed(n)?; + let mut p: Vec = tracker.try_zeroed(n)?; + let ap: Vec = tracker.try_zeroed(n)?; + let r = rhs; + + p.par_iter_mut() + .zip(r.par_iter()) + .zip(inv_diag.par_iter()) + .with_min_len(PAR_MIN_LEN) + .for_each(|((pi, ri), di)| *pi = S::from_f64(ri.to_f64() * di.to_f64())); + let rz = weighted_dot(&r, &inv_diag); + let r_norm = dot_s(&r, &r).sqrt(); + let rhs_norm = r_norm.max(1e-300); + let residual = r_norm / rhs_norm; + + let done = if residual <= tol { + Some(CgStatus::Converged) + } else if max_iter == 0 { + Some(CgStatus::MaxIter) + } else { + None + }; + Ok(Self { + x, + r, + p, + ap, + inv_diag, + rz, + rhs_norm, + residual, + tol, + max_iter, + iterations: 0, + done, + best: f64::INFINITY, + since_best: 0, + reason: None, + }) + } + + /// Start from `x0` and not from zero. Call it before the first `step`. + /// `r` becomes `rhs - N x0`. `ap` is the work vector for `N x0`, so no + /// new vector is made. The residual stays relative to `|rhs|`. + /// `op(v, out)` must write `out = N v`. + pub fn restart_from(&mut self, x0: &[f64], op: &mut dyn FnMut(&[S], &mut [S])) { + debug_assert_eq!(self.iterations, 0); + self.x + .par_iter_mut() + .zip(x0.par_iter()) + .with_min_len(PAR_MIN_LEN) + .for_each(|(xi, v)| *xi = S::from_f64(*v)); + op(&self.x, &mut self.ap); // ap = N x0 + self.r + .par_iter_mut() + .zip(self.ap.par_iter()) + .with_min_len(PAR_MIN_LEN) + .for_each(|(ri, a)| *ri = S::from_f64(ri.to_f64() - a.to_f64())); + self.p + .par_iter_mut() + .zip(self.r.par_iter()) + .zip(self.inv_diag.par_iter()) + .with_min_len(PAR_MIN_LEN) + .for_each(|((pi, ri), di)| *pi = S::from_f64(ri.to_f64() * di.to_f64())); + self.rz = weighted_dot(&self.r, &self.inv_diag); + self.residual = dot_s(&self.r, &self.r).sqrt() / self.rhs_norm; + self.done = if self.residual <= self.tol { + Some(CgStatus::Converged) + } else if self.max_iter == 0 { + Some(CgStatus::MaxIter) + } else { + None + }; + } + + /// Number of CG iterations done. + pub fn iterations(&self) -> usize { + self.iterations + } + + /// Relative residual `|r| / |rhs|`. + pub fn residual(&self) -> f64 { + self.residual + } + + /// Text that tells why a single precision run stopped early, if it did. + pub fn stop_reason(&self) -> Option<&'static str> { + self.reason + } + + /// Free `r`, `p`, `ap` and `inv_diag`. Call it only when the run is done. + /// `tracker` gets the bytes back. + pub fn release_work(&mut self, tracker: &mut MemTracker) { + let item = std::mem::size_of::() as u64; + let count = self.r.len() + self.p.len() + self.ap.len() + self.inv_diag.len(); + self.r = Vec::new(); + self.p = Vec::new(); + self.ap = Vec::new(); + self.inv_diag = Vec::new(); + tracker.release((count as u64).saturating_mul(item)); + } + + /// Run at most `iters` iterations. `op(v, out)` must write `out = N v`. + pub fn step(&mut self, op: &mut dyn FnMut(&[S], &mut [S]), iters: usize) -> CgStatus { + if let Some(status) = self.done { + return status; + } + let single = is_single::(); + let mut left = iters; + while left > 0 { + if self.iterations >= self.max_iter { + self.done = Some(CgStatus::MaxIter); + return CgStatus::MaxIter; + } + op(&self.p, &mut self.ap); // ap = N p + let p_ap = dot_s(&self.p, &self.ap); + if single && !p_ap.is_finite() { + self.reason = Some("the residual became NaN"); + self.done = Some(CgStatus::Breakdown); + return CgStatus::Breakdown; + } + if p_ap.abs() < 1e-300 { + self.done = Some(CgStatus::Breakdown); + return CgStatus::Breakdown; + } + let alpha = self.rz / p_ap; + self.x + .par_iter_mut() + .zip(self.p.par_iter()) + .with_min_len(PAR_MIN_LEN) + .for_each(|(xi, pi)| *xi = S::from_f64(xi.to_f64() + alpha * pi.to_f64())); + self.r + .par_iter_mut() + .zip(self.ap.par_iter()) + .with_min_len(PAR_MIN_LEN) + .for_each(|(ri, api)| *ri = S::from_f64(ri.to_f64() - alpha * api.to_f64())); + self.iterations += 1; + left -= 1; + self.residual = dot_s(&self.r, &self.r).sqrt() / self.rhs_norm; + if self.residual <= self.tol { + self.done = Some(CgStatus::Converged); + return CgStatus::Converged; + } + if single { + if !self.residual.is_finite() { + self.reason = Some("the residual became NaN"); + self.done = Some(CgStatus::Breakdown); + return CgStatus::Breakdown; + } + if self.residual < F32_STAGNATION_LEVEL { + if self.residual < self.best * 0.99 { + self.best = self.residual; + self.since_best = 0; + } else { + self.since_best += 1; + if self.since_best >= F32_STAGNATION_ITERS { + self.reason = Some("the residual does not fall any more"); + self.done = Some(CgStatus::Breakdown); + return CgStatus::Breakdown; + } + } + } + } + let rz_new = weighted_dot(&self.r, &self.inv_diag); + let beta = rz_new / self.rz; + self.p + .par_iter_mut() + .zip(self.r.par_iter()) + .zip(self.inv_diag.par_iter()) + .with_min_len(PAR_MIN_LEN) + .for_each(|((pi, ri), di)| { + *pi = S::from_f64(ri.to_f64() * di.to_f64() + beta * pi.to_f64()) + }); + self.rz = rz_new; + } + if self.iterations >= self.max_iter { + self.done = Some(CgStatus::MaxIter); + return CgStatus::MaxIter; + } + CgStatus::Running { + iteration: self.iterations, + residual: self.residual, + } + } +} + +/// `sum(r * r * w)`, added in `f64`. +#[inline] +pub(crate) fn weighted_dot(r: &[S], w: &[S]) -> f64 { + r.par_iter() + .zip(w.par_iter()) + .with_min_len(PAR_MIN_LEN) + .map(|(ri, wi)| ri.to_f64() * ri.to_f64() * wi.to_f64()) + .sum::() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn solves_diagonal_system() { + // N = diag(1,2,3,4), rhs = [1,2,3,4] => x = [1,1,1,1] + let diag = [1.0, 2.0, 3.0, 4.0]; + let apply = |x: &[f64], out: &mut [f64]| { + for (o, (xi, d)) in out.iter_mut().zip(x.iter().zip(diag.iter())) { + *o = xi * d; + } + }; + let rhs = vec![1.0, 2.0, 3.0, 4.0]; + let result = conjugate_gradient(apply, &rhs, None, 100, 1e-10); + assert!(result.converged); + for v in result.x { + assert!((v - 1.0).abs() < 1e-8); + } + } + + #[test] + fn jacobi_preconditioner_matches_plain_cg_solution() { + let diag = [1.0, 2.0, 3.0, 4.0, 5.0]; + let apply = |x: &[f64], out: &mut [f64]| { + for (o, (xi, d)) in out.iter_mut().zip(x.iter().zip(diag.iter())) { + *o = xi * d; + } + }; + let rhs = vec![2.0, 2.0, 2.0, 2.0, 2.0]; + let precondition = |r: &[f64], out: &mut [f64]| { + for (o, (ri, d)) in out.iter_mut().zip(r.iter().zip(diag.iter())) { + *o = ri / d; + } + }; + + let plain = conjugate_gradient(apply, &rhs, None, 100, 1e-12); + let preconditioned = + conjugate_gradient_preconditioned(apply, precondition, &rhs, None, 100, 1e-12); + + assert!(preconditioned.iterations <= plain.iterations); + for (a, b) in plain.x.iter().zip(preconditioned.x.iter()) { + assert!((a - b).abs() < 1e-6); + } + } +} diff --git a/packages/loop_interpolation_core/src/class_stencil.rs b/packages/loop_interpolation_core/src/class_stencil.rs new file mode 100644 index 00000000..e4ee7d4e --- /dev/null +++ b/packages/loop_interpolation_core/src/class_stencil.rs @@ -0,0 +1,314 @@ +//! Per-class regularisation kernels. +//! +//! The regularisation part of the normal operator is +//! `sum_family w^2 S^T S + sum_border w^2 B^T B`. Its row at node `p` is a +//! stencil of radius 2. The stencil depends only on which rows of `S` and +//! `B` touch `p`: +//! +//! * A curvature row at `r` is active when `m <= r[a] <= n[a] - 1 - m` on +//! every axis `a` (the margin `m` is 1). The test is separable by axis. +//! * A border row lives on one layer of one axis, at every position on the +//! other two axes. Only the coordinate on its own axis matters. +//! +//! So the row at `p` depends only on the class of each coordinate. There +//! are 7 classes per axis: the first, second and third node, the interior, +//! and the mirrored three at the other end. That gives 7^3 = 343 kernels. +//! Each kernel is a sum of products of the family taps and weights, so it +//! is built directly from the stencils (not by probing the operator). +//! +//! The kernels replace the fused-interior pass, the shell correction and +//! the border passes, and their scratch buffers. They need at least +//! [`MIN_AXIS_NODES`] nodes on every axis, so that the interior class is +//! not empty. They do not cover directional regularisation, whose weights +//! change from node to node. + +use crate::PAR_MIN_LEN; +use crate::grid::StructuredGrid3D; +use crate::par::*; +use crate::scalar::Scalar; +use crate::stencil::{EdgeStencil, Stencil}; + +/// Classes on one axis. +const N_CLASS: usize = 7; +/// The interior class. +const INTERIOR: usize = 3; +/// Least number of nodes on an axis for the class kernels. +pub(crate) const MIN_AXIS_NODES: usize = 7; +/// Taps of the dense 5 x 5 x 5 work array (offsets -2..=2 on each axis). +const DENSE: usize = 125; +/// Nodes in one block of an interior run. +const BLOCK: usize = 64; +/// Upper limit of [`ClassKernels::bytes`] (83,704 bytes with all weights +/// on; a zero weight only removes taps). The memory estimate uses it. +pub(crate) const MAX_BYTES: u64 = 86_016; + +/// Class of coordinate `p` on an axis with `n` nodes (`n >= MIN_AXIS_NODES`). +#[inline] +fn axis_class(p: usize, n: usize) -> usize { + if p < INTERIOR { + p + } else if p + INTERIOR >= n { + N_CLASS - (n - p) + } else { + INTERIOR + } +} + +/// A coordinate of class `c` on an axis with `n` nodes. +fn class_coordinate(c: usize, n: usize) -> usize { + if c <= INTERIOR { c } else { n - (N_CLASS - c) } +} + +/// Index of an offset in the dense work array. +#[inline] +fn dense_index(e: [i32; 3]) -> usize { + (e[0] + 2) as usize + 5 * ((e[1] + 2) as usize + 5 * (e[2] + 2) as usize) +} + +/// The 343 class kernels of one grid shape and one set of weights. +pub(crate) struct ClassKernels { + nsteps: [usize; 3], + /// Kernel of class `(cx, cy, cz)` at `cx + 7 * (cy + 7 * cz)`. + /// A tap is (flat index offset, coefficient). + kernels: Vec>, +} + +impl ClassKernels { + /// Build the kernels. `None` if the grid or the stencils do not fit the + /// class model: an axis with fewer than `MIN_AXIS_NODES` nodes, or a tap + /// farther than one step. + pub(crate) fn build( + grid: &StructuredGrid3D, + stencils: &[(Stencil, f64)], + borders: &[(EdgeStencil, f64)], + ) -> Option { + let n = grid.nsteps; + if n.iter().any(|&a| a < MIN_AXIS_NODES) { + return None; + } + let near = |d: i32| (-1..=1).contains(&d); + if stencils.iter().any(|(s, _)| { + s.offsets + .iter() + .any(|d| !(near(d.0) && near(d.1) && near(d.2))) + }) || borders + .iter() + .any(|(b, _)| b.taps.iter().any(|(o, _)| !near(*o))) + { + return None; + } + + let nx = n[0] as i64; + let nxy = nx * n[1] as i64; + let mut kernels = Vec::with_capacity(N_CLASS * N_CLASS * N_CLASS); + for cz in 0..N_CLASS { + for cy in 0..N_CLASS { + for cx in 0..N_CLASS { + let p = [ + class_coordinate(cx, n[0]) as i64, + class_coordinate(cy, n[1]) as i64, + class_coordinate(cz, n[2]) as i64, + ]; + let (sum, abs_sum) = Self::dense_kernel(n, p, stencils, borders); + // A tap is kept if it is not the round-off of terms that + // cancel. A true zero has no terms at all. + let mut taps: Vec<(i64, f64)> = (0..DENSE) + .filter(|&t| abs_sum[t] > 0.0 && sum[t].abs() > 1e-13 * abs_sum[t]) + .map(|t| { + let e = [ + (t % 5) as i64 - 2, + ((t / 5) % 5) as i64 - 2, + (t / 25) as i64 - 2, + ]; + (e[0] + nx * e[1] + nxy * e[2], sum[t]) + }) + .collect(); + // Keep the table at its exact size (see `MAX_BYTES`). + taps.shrink_to_fit(); + kernels.push(taps); + } + } + } + Some(Self { nsteps: n, kernels }) + } + + /// The kernel at node `p` as a dense array, with the sum of the + /// absolute values of the terms of each tap. + fn dense_kernel( + n: [usize; 3], + p: [i64; 3], + stencils: &[(Stencil, f64)], + borders: &[(EdgeStencil, f64)], + ) -> ([f64; DENSE], [f64; DENSE]) { + let mut sum = [0.0f64; DENSE]; + let mut abs_sum = [0.0f64; DENSE]; + let mut add = |e: [i32; 3], v: f64| { + let t = dense_index(e); + sum[t] += v; + abs_sum[t] += v.abs(); + }; + // Curvature: p is tap d1 of the row at r = p - d1. That row reads + // x[r + d2] = x[p + (d2 - d1)]. + for (stencil, weight) in stencils { + if *weight == 0.0 { + continue; + } + let w2 = weight * weight; + for (d1, c1) in stencil.offsets.iter().zip(stencil.coeffs.iter()) { + let d1 = [d1.0, d1.1, d1.2]; + let active = (0..3).all(|a| { + let r = p[a] - d1[a] as i64; + let m = stencil.margin[a] as i64; + r >= m && r <= n[a] as i64 - 1 - m + }); + if !active { + continue; + } + for (d2, c2) in stencil.offsets.iter().zip(stencil.coeffs.iter()) { + let e = [d2.0 - d1[0], d2.1 - d1[1], d2.2 - d1[2]]; + add(e, w2 * c1 * c2); + } + } + } + // Border: a row on `layer` of `axis` reads only along that axis. + for (border, weight) in borders { + if *weight == 0.0 { + continue; + } + let w2 = weight * weight; + let a = border.axis; + for (o1, t1) in &border.taps { + if p[a] - *o1 as i64 != border.layer as i64 { + continue; + } + for (o2, t2) in &border.taps { + let mut e = [0i32; 3]; + e[a] = o2 - o1; + add(e, w2 * t1 * t2); + } + } + } + (sum, abs_sum) + } + + /// Bytes held by the kernels. + pub(crate) fn bytes(&self) -> usize { + self.kernels + .iter() + .map(|k| k.capacity() * std::mem::size_of::<(i64, f64)>()) + .sum::() + + self.kernels.capacity() * std::mem::size_of::>() + } + + /// Write `out = (sum_family w^2 S^T S + sum_border w^2 B^T B + r2 I) x` + /// in one sweep. Each x-line has its edge nodes, which use their class + /// kernel, and one run of interior nodes that share one kernel. The run + /// is done as contiguous multiply-add passes, one for each tap. All sums + /// are in `f64`. `out` is overwritten. + pub(crate) fn apply_into(&self, x: &[S], r2: f64, out: &mut [S]) { + let [nx, ny, nz] = self.nsteps; + let min_lines = (PAR_MIN_LEN / nx).max(1); + out.par_chunks_mut(nx) + .enumerate() + .with_min_len(min_lines) + .for_each(|(line, o)| { + let cy = axis_class(line % ny, ny); + let cz = axis_class(line / ny, nz); + let row = N_CLASS * (cy + N_CLASS * cz); + let base = line * nx; + for i in (0..INTERIOR).chain(nx - INTERIOR..nx) { + let taps = &self.kernels[axis_class(i, nx) + row]; + o[i] = node(taps, x, r2, base + i); + } + let taps = &self.kernels[INTERIOR + row]; + run(taps, x, r2, base, INTERIOR, nx - INTERIOR, o); + }); + } +} + +/// One node: `r2 x[p] + sum_tap c x[p + offset]`. +#[inline] +fn node(taps: &[(i64, f64)], x: &[S], r2: f64, p: usize) -> S { + let mut acc = r2 * x[p].to_f64(); + for &(off, c) in taps { + acc += c * x[(p as i64 + off) as usize].to_f64(); + } + S::from_f64(acc) +} + +/// Nodes `start..end` of the line that starts at flat index `base`, which +/// all use `taps`. Blocks of `BLOCK` nodes are summed tap by tap into an +/// `f64` buffer on the stack, so each pass is a contiguous multiply-add. +fn run( + taps: &[(i64, f64)], + x: &[S], + r2: f64, + base: usize, + start: usize, + end: usize, + o: &mut [S], +) { + let mut buf = [0.0f64; BLOCK]; + let mut s = start; + while s < end { + let len = (end - s).min(BLOCK); + let acc = &mut buf[..len]; + let p0 = base + s; + for (a, xi) in acc.iter_mut().zip(&x[p0..p0 + len]) { + *a = r2 * xi.to_f64(); + } + for &(off, c) in taps { + let q0 = (p0 as i64 + off) as usize; + for (a, xi) in acc.iter_mut().zip(&x[q0..q0 + len]) { + *a += c * xi.to_f64(); + } + } + for (oi, a) in o[s..s + len].iter_mut().zip(acc.iter()) { + *oi = S::from_f64(*a); + } + s += len; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn kernel_table_size() { + use crate::stencil::{dxx, dxy, dxz, dyy, dyz, dzz, edge_backward, edge_forward}; + let grid = StructuredGrid3D::new([0.0; 3], [10, 10, 10], [1.0; 3]); + let stencils = [ + (dxx(), 1.0), + (dyy(), 1.0), + (dzz(), 1.0), + (dxy(), 1.0), + (dxz(), 1.0), + (dyz(), 1.0), + ]; + let mut borders = Vec::new(); + for axis in 0..3 { + borders.push((edge_forward(axis, &grid.nsteps).unwrap(), 1.0)); + borders.push((edge_backward(axis, &grid.nsteps).unwrap(), 1.0)); + } + let k = ClassKernels::build(&grid, &stencils, &borders).unwrap(); + let taps: usize = k.kernels.iter().map(|v| v.len()).sum(); + let interior = k.kernels[INTERIOR + N_CLASS * (INTERIOR + N_CLASS * INTERIOR)].len(); + println!("bytes={} taps={taps} interior taps={interior}", k.bytes()); + assert_eq!(interior, 25); + assert!(k.bytes() as u64 <= MAX_BYTES, "bytes {}", k.bytes()); + } + + #[test] + fn classes_cover_an_axis() { + let n = 9; + let classes: Vec = (0..n).map(|p| axis_class(p, n)).collect(); + assert_eq!(classes, [0, 1, 2, 3, 3, 3, 4, 5, 6]); + for c in 0..N_CLASS { + assert_eq!(axis_class(class_coordinate(c, n), n), c); + } + let n = 7; + let classes: Vec = (0..n).map(|p| axis_class(p, n)).collect(); + assert_eq!(classes, [0, 1, 2, 3, 4, 5, 6]); + } +} diff --git a/packages/loop_interpolation_core/src/error.rs b/packages/loop_interpolation_core/src/error.rs new file mode 100644 index 00000000..4f3f4298 --- /dev/null +++ b/packages/loop_interpolation_core/src/error.rs @@ -0,0 +1,95 @@ +//! Error types of the core crate. + +use std::fmt; + +/// Kind of a bound constraint. +#[derive(Debug, Clone, PartialEq)] +pub enum BoundKind { + Value, + Pair, +} + +/// A bound constraint that the field violates. +#[derive(Debug, Clone, PartialEq)] +pub struct ViolatedConstraint { + pub index: usize, + pub kind: BoundKind, + pub violation: f64, +} + +/// Error of the core crate. +#[derive(Debug, Clone, PartialEq)] +pub enum CoreError { + /// A constraint is not valid. `constraint` is a text such as "value[12]". + InvalidInput { constraint: String, reason: String }, + /// An axis has fewer than 3 nodes. + GridTooSmall { axis: usize, nodes: usize }, + /// The grid has more nodes than `max_nodes`. + GridTooLarge { nodes: u64, max_nodes: u64 }, + /// The problem has no constraints. + NoConstraints, + /// The memory estimate is more than the budget. + OverBudget { + estimate_bytes: u64, + budget_bytes: u64, + max_nodes_per_axis: usize, + }, + /// An allocation failed. + OutOfMemory { requested_bytes: u64 }, + /// The bounds cannot all be true. At most 20 entries, largest violation first. + Infeasible { worst: Vec }, + /// A point is outside the grid. + PointOutsideGrid { index: usize }, + /// A setting is not supported. + Unsupported { setting: String }, + /// A solve is already running (used by the wasm layer). + SolveRunning, +} + +impl fmt::Display for CoreError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + CoreError::InvalidInput { constraint, reason } => { + write!(f, "invalid input in {constraint}: {reason}") + } + CoreError::GridTooSmall { axis, nodes } => { + write!( + f, + "grid axis {axis} has {nodes} nodes; at least 3 are needed" + ) + } + CoreError::GridTooLarge { nodes, max_nodes } => { + write!(f, "grid has {nodes} nodes; the maximum is {max_nodes}") + } + CoreError::NoConstraints => write!(f, "the problem has no constraints"), + CoreError::OverBudget { + estimate_bytes, + budget_bytes, + max_nodes_per_axis, + } => write!( + f, + "estimated memory {estimate_bytes} bytes is more than the budget {budget_bytes} bytes; the largest grid for the budget has {max_nodes_per_axis} nodes per axis" + ), + CoreError::OutOfMemory { requested_bytes } => { + write!( + f, + "out of memory: could not allocate {requested_bytes} bytes" + ) + } + CoreError::Infeasible { worst } => { + write!( + f, + "the bounds are infeasible; {} worst violations listed", + worst.len() + ) + } + CoreError::PointOutsideGrid { index } => { + write!(f, "point {index} is outside the grid") + } + CoreError::Unsupported { setting } => write!(f, "unsupported setting: {setting}"), + CoreError::SolveRunning => write!(f, "a solve is already running"), + } + } +} + +impl std::error::Error for CoreError {} diff --git a/packages/loop_interpolation_core/src/field.rs b/packages/loop_interpolation_core/src/field.rs new file mode 100644 index 00000000..ee92b343 --- /dev/null +++ b/packages/loop_interpolation_core/src/field.rs @@ -0,0 +1,99 @@ +//! The solved field. + +use crate::error::CoreError; +use crate::grid::StructuredGrid3D; + +/// Values of the field, in the solver precision. +#[derive(Debug, Clone)] +pub enum FieldData { + F64(Vec), + F32(Vec), +} + +/// A solved field and its grid. +#[derive(Debug, Clone)] +pub struct Field { + pub grid: StructuredGrid3D, + pub data: FieldData, +} + +/// Check the length of the points and that each point is inside the grid. +/// Return the points as 3-value arrays. +fn check_points(grid: &StructuredGrid3D, points: &[f64]) -> Result, CoreError> { + if !points.len().is_multiple_of(3) { + return Err(CoreError::InvalidInput { + constraint: "points".into(), + reason: format!("length {} is not a multiple of 3", points.len()), + }); + } + // The length is a multiple of 3, so there is no remainder. + let pts: Vec<[f64; 3]> = points.as_chunks::<3>().0.to_vec(); + // The grid code computes (p - origin) / step in f64. NaN is outside. + for (index, p) in pts.iter().enumerate() { + if grid.trilinear_coeffs(*p).is_none() { + return Err(CoreError::PointOutsideGrid { index }); + } + } + Ok(pts) +} + +impl Field { + /// Return the values as `f64`. + pub fn values_f64(&self) -> Vec { + match &self.data { + FieldData::F64(v) => v.clone(), + FieldData::F32(v) => v.iter().map(|x| *x as f64).collect(), + } + } + + /// Evaluate the field. `points` has 3m values. The result has m values. + pub fn evaluate(&self, points: &[f64]) -> Result, CoreError> { + let pts = check_points(&self.grid, points)?; + let owned; + let c: &[f64] = match &self.data { + FieldData::F64(v) => v, + FieldData::F32(_) => { + owned = self.values_f64(); + &owned + } + }; + // Same formula as the ported interpolator evaluate method. + Ok(pts + .iter() + .map(|p| match self.grid.trilinear_coeffs(*p) { + Some((idx, w)) => idx.iter().zip(w.iter()).map(|(i, w)| w * c[*i]).sum(), + None => f64::NAN, + }) + .collect()) + } + + /// Evaluate the gradient. `points` has 3m values. The result has 3m values. + pub fn evaluate_gradient(&self, points: &[f64]) -> Result, CoreError> { + let pts = check_points(&self.grid, points)?; + let owned; + let c: &[f64] = match &self.data { + FieldData::F64(v) => v, + FieldData::F32(_) => { + owned = self.values_f64(); + &owned + } + }; + let mut out = Vec::with_capacity(points.len()); + for p in &pts { + match self.grid.trilinear_gradient(*p) { + Some((idx, grad)) => { + for axis in 0..3 { + out.push( + idx.iter() + .zip(grad.iter()) + .map(|(i, g)| c[*i] * g[axis]) + .sum(), + ); + } + } + None => out.extend([f64::NAN; 3]), + } + } + Ok(out) + } +} diff --git a/packages/loop_interpolation_core/src/grid.rs b/packages/loop_interpolation_core/src/grid.rs new file mode 100644 index 00000000..bd7615f3 --- /dev/null +++ b/packages/loop_interpolation_core/src/grid.rs @@ -0,0 +1,272 @@ +//! Regular (axis-aligned, uniform-spacing) 3D grid support. +//! +//! Node layout matches the Python `StructuredGrid`: nodes are flattened in +//! Fortran (column-major) order, `flat = i + nx * (j + ny * k)`, so that a +//! flat field can be reshaped as `arr.reshape((nx, ny, nz), order="F")` on +//! the Python side for cross-checking. + +#[derive(Debug, Clone, Copy)] +pub struct StructuredGrid3D { + pub origin: [f64; 3], + pub nsteps: [usize; 3], + pub step_vector: [f64; 3], +} + +impl StructuredGrid3D { + pub fn new(origin: [f64; 3], nsteps: [usize; 3], step_vector: [f64; 3]) -> Self { + Self { + origin, + nsteps, + step_vector, + } + } + + #[inline] + pub fn n_nodes(&self) -> usize { + self.nsteps[0] * self.nsteps[1] * self.nsteps[2] + } + + #[inline] + pub fn index(&self, i: usize, j: usize, k: usize) -> usize { + i + self.nsteps[0] * (j + self.nsteps[1] * k) + } + + /// Inverse of [`index`]: flat -> (i, j, k). + #[inline] + pub fn node_ijk(&self, flat: usize) -> (usize, usize, usize) { + let nx = self.nsteps[0]; + let ny = self.nsteps[1]; + let i = flat % nx; + let j = (flat / nx) % ny; + let k = flat / (nx * ny); + (i, j, k) + } + + /// Signed neighbour lookup used by stencil operators. Returns `None` if + /// the offset neighbour would fall outside the grid. + #[inline] + pub fn neighbour_index( + &self, + i: usize, + j: usize, + k: usize, + d: (i32, i32, i32), + ) -> Option { + let ni = i as i64 + d.0 as i64; + let nj = j as i64 + d.1 as i64; + let nk = k as i64 + d.2 as i64; + if ni < 0 + || nj < 0 + || nk < 0 + || ni >= self.nsteps[0] as i64 + || nj >= self.nsteps[1] as i64 + || nk >= self.nsteps[2] as i64 + { + None + } else { + Some(self.index(ni as usize, nj as usize, nk as usize)) + } + } + + /// Shared local-frame cell lookup used by both `trilinear_coeffs` and + /// `trilinear_gradient`: given an already-local-frame point, returns the + /// lower-corner cell index `i0` and the fractional local coordinates `t` + /// (each clamped to `[0, 1]`), or `None` if the point falls outside the + /// grid's bounding box. + fn cell_and_local_coords(&self, point: [f64; 3]) -> Option<([usize; 3], [f64; 3])> { + let mut i0 = [0usize; 3]; + let mut t = [0.0f64; 3]; + for axis in 0..3 { + let n = self.nsteps[axis]; + if n < 2 { + return None; + } + let step = self.step_vector[axis]; + let rel = (point[axis] - self.origin[axis]) / step; + let max_index = (n - 1) as f64; + // Small epsilon tolerance so points exactly on the upper/lower + // boundary are accepted rather than rejected by fp rounding. + // + // Written as "reject unless provably inside" (rather than the + // logically-equivalent-for-finite-values `rel < -EPS || rel > + // max_index + EPS`) so a NaN coordinate is rejected too: every + // comparison against NaN is false, so the `<`/`>` form would + // silently fall through here and then have `rel.clamp(..)` + // coerce NaN to 0.0 a few lines down, i.e. treat a garbage + // coordinate as if it were a real point at the grid's lower + // corner instead of dropping it like any other out-of-bounds + // point. + const EPS: f64 = 1e-9; + if !(rel >= -EPS && rel <= max_index + EPS) { + return None; + } + let clamped = rel.clamp(0.0, max_index); + let mut lo = clamped.floor() as usize; + if lo >= n - 1 { + lo = n - 2; + } + i0[axis] = lo; + t[axis] = (clamped - lo as f64).clamp(0.0, 1.0); + } + Some((i0, t)) + } + + /// Trilinear interpolation coefficients for a point: the 8 corner node + /// indices of the containing cell and their weights (summing to 1). + /// Returns `None` if the point lies outside the grid bounding box. + /// `point` is in world space. + pub fn trilinear_coeffs(&self, point: [f64; 3]) -> Option<([usize; 8], [f64; 8])> { + let (i0, t) = self.cell_and_local_coords(point)?; + + let mut indices = [0usize; 8]; + let mut coefs = [0.0f64; 8]; + for corner in 0..8 { + let di = corner & 1; + let dj = (corner >> 1) & 1; + let dk = (corner >> 2) & 1; + let i = i0[0] + di; + let j = i0[1] + dj; + let k = i0[2] + dk; + let wx = if di == 0 { 1.0 - t[0] } else { t[0] }; + let wy = if dj == 0 { 1.0 - t[1] } else { t[1] }; + let wz = if dk == 0 { 1.0 - t[2] } else { t[2] }; + indices[corner] = self.index(i, j, k); + coefs[corner] = wx * wy * wz; + } + Some((indices, coefs)) + } + + /// Trilinear *gradient* shape function: the 8 corner node indices (same + /// order/indices [`trilinear_coeffs`] would return for the same point) + /// and, for each corner, `d(coef)/d(x_local), d(coef)/d(y_local), + /// d(coef)/d(z_local)` -- i.e. the analytic derivative of the same + /// trilinear basis `trilinear_coeffs` computes, divided by + /// `step_vector`. This is the Rust analogue of the Python + /// `StructuredGrid.get_element_gradient_for_location`'s `T` matrix + /// (shape `(3, 8)` per point; here returned per-corner as `[f64; 3]` for + /// cache-friendliness). `point` is in world space. + pub fn trilinear_gradient(&self, point: [f64; 3]) -> Option<([usize; 8], [[f64; 3]; 8])> { + let (i0, t) = self.cell_and_local_coords(point)?; + + let mut indices = [0usize; 8]; + let mut grad = [[0.0f64; 3]; 8]; + for corner in 0..8 { + let di = corner & 1; + let dj = (corner >> 1) & 1; + let dk = (corner >> 2) & 1; + let i = i0[0] + di; + let j = i0[1] + dj; + let k = i0[2] + dk; + indices[corner] = self.index(i, j, k); + + let wx = if di == 0 { 1.0 - t[0] } else { t[0] }; + let wy = if dj == 0 { 1.0 - t[1] } else { t[1] }; + let wz = if dk == 0 { 1.0 - t[2] } else { t[2] }; + let dwx = if di == 0 { -1.0 } else { 1.0 }; + let dwy = if dj == 0 { -1.0 } else { 1.0 }; + let dwz = if dk == 0 { -1.0 } else { 1.0 }; + + grad[corner][0] = dwx * wy * wz / self.step_vector[0]; + grad[corner][1] = wx * dwy * wz / self.step_vector[1]; + grad[corner][2] = wx * wy * dwz / self.step_vector[2]; + } + Some((indices, grad)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn small_grid() -> StructuredGrid3D { + StructuredGrid3D::new([0.0, 0.0, 0.0], [3, 3, 3], [1.0, 1.0, 1.0]) + } + + #[test] + fn index_roundtrip() { + let grid = small_grid(); + for k in 0..3 { + for j in 0..3 { + for i in 0..3 { + let flat = grid.index(i, j, k); + assert_eq!(grid.node_ijk(flat), (i, j, k)); + } + } + } + } + + #[test] + fn trilinear_at_node_is_one_hot() { + let grid = small_grid(); + let (idx, coefs) = grid.trilinear_coeffs([1.0, 1.0, 1.0]).unwrap(); + let centre = grid.index(1, 1, 1); + for (i, c) in idx.iter().zip(coefs.iter()) { + if *i == centre { + assert!((c - 1.0).abs() < 1e-12); + } else { + assert!(c.abs() < 1e-12); + } + } + } + + #[test] + fn trilinear_outside_bounds_is_none() { + let grid = small_grid(); + assert!(grid.trilinear_coeffs([-1.0, 0.0, 0.0]).is_none()); + assert!(grid.trilinear_coeffs([5.0, 0.0, 0.0]).is_none()); + } + + #[test] + fn trilinear_coefficients_sum_to_one() { + let grid = small_grid(); + let (_, coefs) = grid.trilinear_coeffs([0.3, 1.7, 0.9]).unwrap(); + let sum: f64 = coefs.iter().sum(); + assert!((sum - 1.0).abs() < 1e-12); + } + + #[test] + fn trilinear_gradient_matches_finite_difference() { + // Central-difference check of trilinear_gradient against + // trilinear_coeffs for a handful of interior points. + let grid = StructuredGrid3D::new([0.0, 0.0, 0.0], [6, 6, 6], [1.0, 1.0, 1.0]); + let points = [[2.3, 1.1, 3.7], [0.5, 4.9, 0.2], [3.0, 3.0, 3.0]]; + let eps = 1e-6; + for p in points { + let (idx, grad) = grid.trilinear_gradient(p).unwrap(); + let (idx_c, _coefs_c) = grid.trilinear_coeffs(p).unwrap(); + assert_eq!(idx, idx_c); + // Evaluate a synthetic scalar field (value(node) = node index * + // 0.37) via finite differences on trilinear_coeffs and compare + // to the analytic derivative from trilinear_gradient. + for axis in 0..3 { + let mut p_plus = p; + let mut p_minus = p; + p_plus[axis] += eps; + p_minus[axis] -= eps; + let (idx_p, coefs_p) = grid.trilinear_coeffs(p_plus).unwrap(); + let (idx_m, coefs_m) = grid.trilinear_coeffs(p_minus).unwrap(); + let field = |i: usize| i as f64 * 0.37; + let val_p: f64 = idx_p + .iter() + .zip(coefs_p.iter()) + .map(|(i, c)| field(*i) * c) + .sum(); + let val_m: f64 = idx_m + .iter() + .zip(coefs_m.iter()) + .map(|(i, c)| field(*i) * c) + .sum(); + let fd = (val_p - val_m) / (2.0 * eps); + let analytic: f64 = idx + .iter() + .zip(grad.iter()) + .map(|(i, g)| field(*i) * g[axis]) + .sum(); + assert!( + (fd - analytic).abs() < 1e-4, + "axis={axis} fd={fd} analytic={analytic}" + ); + } + } + } +} diff --git a/packages/loop_interpolation_core/src/interpolator.rs b/packages/loop_interpolation_core/src/interpolator.rs new file mode 100644 index 00000000..63c855ba --- /dev/null +++ b/packages/loop_interpolation_core/src/interpolator.rs @@ -0,0 +1,4845 @@ +//! Matrix-free port of `loop_interpolation`'s `FiniteDifferenceInterpolator`. +//! +//! Ports the ingredients that make the original interesting to reimplement +//! in Rust -- trilinear value/gradient/norm/tangent/interface constraints, +//! isotropic curvature regularisation, centred second-derivative border regularisation, +//! anisotropic directional regularisation -- on a +//! `StructuredGrid`, and solves the resulting weighted least squares system +//! with conjugate gradient on the normal equations, entirely through +//! [`Stencil`]/[`EdgeStencil`] operators (see `stencil.rs`) and dense +//! per-constraint row storage instead of building any sparse matrix. Box +//! (inequality) constraints -- value-bound (`add_value_inequality_constraint`) +//! and pairwise-ordering (`add_inequality_pair_constraint`, e.g. +//! stratigraphic ordering) -- are also matrix-free, stored the same "own +//! row(s), accumulate into the shared operator" way as every equality +//! constraint family, and solved via three interchangeable matrix-free +//! solvers (see `Inequalities.md` for the design rationale behind offering +//! all three rather than just one): +//! +//! - [`FiniteDifferenceInterpolator::solve_admm`]: classical ADMM +//! box-splitting (ported from `loop_interpolation`'s +//! `loopsolver/admm_method.py` + `admm_solver.py`, scoped down to the core +//! fixed-rho algorithm -- see that method's docs for exactly what was +//! descoped). Cheap per outer iteration and handles a dense/high active +//! fraction well, but only ever satisfies bounds up to its primal residual +//! tolerance (asymptotic contact). +//! - [`FiniteDifferenceInterpolator::solve_active_set_cg`]: a primal +//! active-set method (Projected Active-Set Conjugate Gradient) that keeps +//! every active row exactly (to floating-point precision) on its bound, +//! at the cost of an `O(m)` scan per CG step and small dense Gram solves +//! on every working-set change -- intended for a low-to-moderate active +//! fraction. +//! - [`FiniteDifferenceInterpolator::solve_hybrid`]: a fixed-budget ADMM +//! warm-up feeding a working-set guess into an active-set polish, getting +//! ADMM's cheap bulk discovery of which rows are active plus AS-CG's +//! exact contact precision. +//! +//! Anisotropic directional regularisation +//! ([`FiniteDifferenceInterpolator::add_directional_regularisation`]) is the +//! generic primitive fold interpolation's anisotropic smoothing is built +//! from (the Rust analogue of the Python +//! `minimise_directional_gradient_change`): it penalises the directional +//! second derivative `(v.grad)^2 f = 0` at every interior node for a +//! caller-supplied per-node direction field `v`, weighting the six curvature +//! families by a per-node `comp_w = [vx^2, vy^2, vz^2, 2vxvy, 2vxvz, 2vyvz]` +//! instead of one uniform scalar. Since per-node weights can't use the fused +//! single-kernel fast path (`build_combined_kernel` requires a uniform +//! weight), this goes through its own unfused accumulation pass -- see +//! `Stencil::accumulate_normal_weighted_into`/`diagonal_weighted`. Left out on +//! purpose (unlike the Python class): the fold-frame geometry itself (deriving +//! direction vectors from a fold's scalar fields) and column scaling. Column +//! scaling slots into the same `apply_normal_operator` / `rhs` accumulation +//! pattern used here if it's added later. + +use crate::PAR_MIN_LEN; +use crate::cg::{CgResult, conjugate_gradient_preconditioned}; +use crate::class_stencil::ClassKernels; +use crate::error::CoreError; +use crate::grid::StructuredGrid3D; +use crate::memory::MemTracker; +use crate::par::*; +use crate::scalar::Scalar; +use crate::stencil::{ + EdgeStencil, Stencil, dxx, dxy, dxz, dyy, dyz, dzz, edge_backward, edge_forward, +}; +use std::collections::HashMap; +use std::sync::OnceLock; + +/// Number of curvature-regularisation stencil families (dxx, dyy, dzz, dxy, +/// dxz, dyz). `apply_normal_operator_into` computes all six per grid node in +/// a single fused rayon pass (see below) rather than looping over families +/// and dispatching a separate parallel pass per family: on a modest-sized +/// grid, thread-pool coordination overhead for a small parallel task can +/// dwarf the O(1)-taps-per-node arithmetic it's doing, so amortising that +/// overhead across all six families in one dispatch (instead of twelve -- +/// forward and adjoint, times six) is what actually makes this fast. +const N_FAMILIES: usize = 6; + +/// A single (row-normalised) linear constraint over the 8 trilinear corner +/// dofs of one point: `coefs . x[indices] = value`, weighted by `weight`. +/// Used for value, gradient-orthogonal, norm, and tangent constraints alike +/// -- they only differ in how `coefs`/`value` are built (see +/// `add_value_constraint`, `build_orthogonal_constraint`, +/// `add_norm_constraint`), not in how they're accumulated into the normal +/// equations. +struct PointConstraint { + indices: [usize; 8], + coefs: [f64; 8], + value: f64, + weight: f64, +} + +/// A single (row-normalised) linear constraint tying two points' trilinear +/// corner dofs together: `coefs_a . x[indices_a] - coefs_b . x[indices_b] = +/// 0`, weighted by `weight`. Used for interface (equal-value) constraints. +struct PairConstraint { + /// First 8 entries are point A's corner indices, next 8 are point B's. + indices: [usize; 16], + /// Row-normalised combined coefficients: point A's value coefs followed + /// by the *negated* point B's value coefs. + coefs: [f64; 16], + weight: f64, +} + +// The sums run in f64. Only the stored vector values use the type S. +fn accumulate_point_constraints_normal( + constraints: &[PointConstraint], + x: &[S], + out: &mut [S], +) { + for c in constraints { + let mut row_value = 0.0; + for k in 0..8 { + row_value += c.coefs[k] * x[c.indices[k]].to_f64(); + } + let scaled = c.weight * c.weight * row_value; + for k in 0..8 { + let o = &mut out[c.indices[k]]; + *o = S::from_f64(o.to_f64() + scaled * c.coefs[k]); + } + } +} + +fn point_constraints_rhs(constraints: &[PointConstraint], out: &mut [S]) { + for c in constraints { + let scaled = c.weight * c.weight * c.value; + for k in 0..8 { + let o = &mut out[c.indices[k]]; + *o = S::from_f64(o.to_f64() + scaled * c.coefs[k]); + } + } +} + +fn point_constraints_diagonal(constraints: &[PointConstraint], diag: &mut [f64]) { + for c in constraints { + let w2 = c.weight * c.weight; + for k in 0..8 { + diag[c.indices[k]] += w2 * c.coefs[k] * c.coefs[k]; + } + } +} + +fn accumulate_pair_constraints_normal( + constraints: &[PairConstraint], + x: &[S], + out: &mut [S], +) { + for c in constraints { + let mut row_value = 0.0; + for k in 0..16 { + row_value += c.coefs[k] * x[c.indices[k]].to_f64(); + } + let scaled = c.weight * c.weight * row_value; + for k in 0..16 { + let o = &mut out[c.indices[k]]; + *o = S::from_f64(o.to_f64() + scaled * c.coefs[k]); + } + } +} + +fn pair_constraints_diagonal(constraints: &[PairConstraint], diag: &mut [f64]) { + for c in constraints { + let w2 = c.weight * c.weight; + for k in 0..16 { + diag[c.indices[k]] += w2 * c.coefs[k] * c.coefs[k]; + } + } +} + +/// A single box (inequality) constraint over the 8 trilinear corner dofs of +/// one point: `lower <= coefs . x[indices] <= upper`. Used for value-bound +/// inequality constraints (see `add_value_inequality_constraint`). +/// +/// `coefs`/`lower`/`upper` already have both the row's L2 normalisation +/// *and* `weight` folded in (`scale = weight / norm`, applied uniformly to +/// `coefs`, `lower`, and `upper` -- see `add_value_inequality_constraint`), +/// unlike the equality [`PointConstraint`]/[`PairConstraint`] families, which +/// keep `weight` separate and square it in at accumulation time. Folding it +/// in here instead keeps `solve_admm`'s box-projection z-update +/// (`z = clip(Q x + u, lower, upper)`) a single elementwise op against a flat +/// per-row bound pair, with no separate per-row weight to thread through the +/// ADMM state (`z`/`u`) -- `weight` only ever needs to scale this row's +/// contribution to `Q`/the bounds once, at constraint-build time, since a +/// positive rescaling of both a row and its bounds together leaves the +/// constraint it encodes (`lower_raw <= raw_row_value <= upper_raw`) +/// unchanged. +struct InequalityPointConstraint { + indices: [usize; 8], + coefs: [f64; 8], + lower: f64, + upper: f64, +} + +/// As [`InequalityPointConstraint`], but over the *difference* of two +/// points' trilinear corner dofs (`coefs_a . x[indices_a] - coefs_b . +/// x[indices_b]`), bounded to `[lower, upper]` instead of forced to zero -- +/// the pairwise-ordering case (see `add_inequality_pair_constraint`), e.g. +/// stratigraphic ordering (`lower = -inf`, `upper = 0` encodes "unit A's +/// value must not exceed unit B's"). +struct InequalityPairConstraint { + /// First 8 entries are point A's corner indices, next 8 are point B's. + indices: [usize; 16], + /// Row-normalised, weight-scaled combined coefficients: point A's value + /// coefs followed by the *negated* point B's value coefs (see + /// [`PairConstraint::coefs`]'s doc comment for the same layout). + coefs: [f64; 16], + lower: f64, + upper: f64, +} + +#[inline] +fn ineq_point_row_value(c: &InequalityPointConstraint, x: &[S]) -> f64 { + let mut v = 0.0; + for k in 0..8 { + v += c.coefs[k] * x[c.indices[k]].to_f64(); + } + v +} + +#[inline] +fn ineq_pair_row_value(c: &InequalityPairConstraint, x: &[S]) -> f64 { + let mut v = 0.0; + for k in 0..16 { + v += c.coefs[k] * x[c.indices[k]].to_f64(); + } + v +} + +/// One row of [`FiniteDifferenceInterpolator::solve_active_set_cg`]'s +/// working set: inequality row `row` (in the flattened value-then-pair order +/// [`FiniteDifferenceInterpolator::inequality_row`] uses) held exactly at +/// `bound`. `sign` is `+1.0` when `bound` is that row's *lower* bound +/// (binding as `Q_row x >= lower`) and `-1.0` when it's the *upper* bound +/// (`Q_row x <= upper`) -- only used to get the Lagrange-multiplier release +/// check's sign right (see `active_set_cg_core`'s doc comment). +#[derive(Clone, Copy)] +struct ActiveRow { + row: usize, + bound: f64, + sign: f64, +} + +/// Dense Cholesky factorisation of a small SPD matrix `a` (row-major, `n x +/// n`): returns the lower-triangular factor `L` (same row-major layout) +/// with `A = L L^T`, or `None` if a pivot is non-positive. Used for the +/// small `|W| x |W|` active-working-set Gram systems in +/// `active_set_cg_core` -- `|W|` is expected to be tiny relative to `dof()` +/// (see the "Constraint Density" row of `Inequalities.md`'s decision +/// matrix), so a dense direct factorisation here is the simplest and +/// fastest option, not a departure from the crate's matrix-free stance on +/// the *grid* operator `N` (which stays completely implicit). +fn cholesky(a: &[f64], n: usize) -> Option> { + let mut l = vec![0.0; n * n]; + for i in 0..n { + for j in 0..=i { + let mut sum = a[i * n + j]; + for k in 0..j { + sum -= l[i * n + k] * l[j * n + k]; + } + if i == j { + if sum <= 0.0 { + return None; + } + l[i * n + j] = sum.sqrt(); + } else { + l[i * n + j] = sum / l[j * n + j]; + } + } + } + Some(l) +} + +/// Solve `A y = b` given `A`'s Cholesky factor `l` (see [`cholesky`]), via +/// forward + back substitution. +fn cholesky_solve(l: &[f64], n: usize, b: &[f64]) -> Vec { + let mut y = vec![0.0; n]; + for i in 0..n { + let mut sum = b[i]; + for k in 0..i { + sum -= l[i * n + k] * y[k]; + } + y[i] = sum / l[i * n + i]; + } + let mut x = vec![0.0; n]; + for i in (0..n).rev() { + let mut sum = y[i]; + for k in (i + 1)..n { + sum -= l[k * n + i] * x[k]; + } + x[i] = sum / l[i * n + i]; + } + x +} + +/// `Q_W v`: dot of `v` against each active row, in working-set order. +fn active_set_apply( + interp: &FiniteDifferenceInterpolator, + active: &[ActiveRow], + v: &[f64], +) -> Vec { + active + .iter() + .map(|a| interp.inequality_row_dot(a.row, v)) + .collect() +} + +/// Dense Gram matrix `Q_W Q_W^T` (row-major, `|W| x |W|`), with a tiny +/// relative ridge added to the diagonal so near-duplicate/colinear active +/// rows (e.g. two inequality constraints that happen to coincide) keep the +/// Cholesky factorisation well-defined instead of failing outright. +fn active_set_gram(interp: &FiniteDifferenceInterpolator, active: &[ActiveRow]) -> Vec { + let w = active.len(); + let mut g = vec![0.0; w * w]; + for a in 0..w { + for b in a..w { + let v = interp.inequality_row_dot_rows(active[a].row, active[b].row); + g[a * w + b] = v; + g[b * w + a] = v; + } + } + g +} + +/// Cholesky-factorise a working-set Gram matrix (see [`active_set_gram`]), +/// trying the matrix exactly as given first. A ridge is *not* added +/// up-front: every active row is already L2-normalised to unit norm at +/// constraint-build time (see `add_value_inequality_constraint`'s docs), so +/// a genuinely independent working set's Gram matrix already has diagonal +/// entries of exactly `1.0` and is well-conditioned -- and this +/// factorisation feeds directly into `active_set_restore_feasibility`'s +/// exact affine correction and the multiplier release check, where even a +/// tiny relative perturbation (a naive fixed +/// ridge) leaks a fraction of the -- potentially large -- unconstrained +/// residual back into the "should be pinned" active dofs every CG +/// iteration, silently drifting `x` off the active bound over many +/// iterations instead of keeping it exact. A ridge is only introduced as a +/// fallback, and only just large enough to make the *exact* Gram +/// factorisable, for the rare case of near-duplicate/colinear active rows +/// (e.g. two inequality constraints that happen to coincide). +fn active_set_cholesky(gram: &[f64], w: usize) -> Vec { + if let Some(l) = cholesky(gram, w) { + return l; + } + let diag_max = (0..w).map(|i| gram[i * w + i]).fold(1.0_f64, f64::max); + let mut ridge = 1e-12 * diag_max; + loop { + let mut g = gram.to_vec(); + for i in 0..w { + g[i * w + i] += ridge; + } + if let Some(l) = cholesky(&g, w) { + return l; + } + ridge *= 10.0; + assert!( + ridge < diag_max.max(1.0) * 1e6, + "active working set's Gram matrix is not positive definite even after ridge regularisation -- likely a bug in how active rows are constructed" + ); + } +} + +/// Dense Gram matrix `Q_W diag(dinv) Q_W^T` (row-major, `|W| x |W|`) -- the +/// working set's Gram matrix in the `M^-1 = diag(dinv)` (Jacobi) inner +/// product, used for [`active_set_project_weighted`]'s preconditioned +/// null-space projection. Unlike [`active_set_gram`] (the plain `Q_W Q_W^T` +/// used for exact feasibility restoration and the Lagrange-multiplier +/// release check, both of which are properties of the *original* unweighted +/// problem), this one exists purely to make the projected CG search +/// converge in a comparable number of iterations to +/// [`FiniteDifferenceInterpolator::solve`]'s Jacobi-preconditioned CG, +/// rather than plain (unpreconditioned) CG's typically much slower +/// convergence on this crate's regularised normal-equations operator. +fn active_set_gram_weighted( + interp: &FiniteDifferenceInterpolator, + active: &[ActiveRow], + dinv: &[f64], +) -> Vec { + let w = active.len(); + let mut g = vec![0.0; w * w]; + for a in 0..w { + for b in a..w { + let v = interp.inequality_row_dot_rows_weighted(active[a].row, active[b].row, dinv); + g[a * w + b] = v; + g[b * w + a] = v; + } + } + g +} + +/// Jacobi-preconditioned, null-space-projected residual: `M^-1 v - M^-1 +/// Q_W^T (Q_W M^-1 Q_W^T)^-1 Q_W (M^-1 v)`, `M^-1 = diag(dinv)` -- the +/// projected-preconditioned-CG generalisation (Nocedal & Wright, "Numerical +/// Optimization", Algorithm 16.2) of [`FiniteDifferenceInterpolator::solve`]'s +/// plain Jacobi preconditioner to the working set's null space. `chol` is +/// [`active_set_gram_weighted`]'s Cholesky factor (`None` iff the working +/// set is empty, in which case this degenerates to plain Jacobi +/// preconditioning, matching `solve`'s unconstrained behaviour). +/// +/// Note this is *not* interchangeable with [`active_set_restore_feasibility`] +/// or the Lagrange-multiplier release check in `active_set_cg_core`: both of +/// those need the *exact*, unweighted `Q_W Q_W^T` system (see +/// [`active_set_gram`]/[`active_set_apply`]) since they answer questions +/// about the original (unweighted) QP, not about how fast this +/// preconditioner happens to converge. +fn active_set_project_weighted( + interp: &FiniteDifferenceInterpolator, + active: &[ActiveRow], + chol: Option<&[f64]>, + dinv: &[f64], + v: &[f64], +) -> Vec { + let weighted: Vec = v.iter().zip(dinv.iter()).map(|(vi, di)| vi * di).collect(); + let w = active.len(); + let chol = match chol { + Some(c) if w > 0 => c, + _ => return weighted, + }; + let qv = active_set_apply(interp, active, &weighted); + let y = cholesky_solve(chol, w, &qv); + let mut out = weighted; + for (a, yi) in active.iter().zip(y.iter()) { + let (idx, coefs) = interp.inequality_row(a.row); + let s = -*yi; + for (i, c) in idx.iter().zip(coefs.iter()) { + out[*i] += s * c * dinv[*i]; + } + } + out +} + +/// Correct `x` in place to satisfy `Q_W x = bound_W` exactly, via the +/// minimum-Euclidean-distance affine projection `x <- x - Q_W^T (Q_W +/// Q_W^T)^-1 (Q_W x - bound_W)`. A no-op (up to floating point) whenever `x` +/// already satisfies every active row exactly -- in particular this makes it +/// safe to call unconditionally after *every* working-set change (a row +/// joining or leaving), including right after a mid-CG step-truncation event +/// where `x` is already exactly on the new boundary by construction (see +/// `active_set_cg_core`): the correction there is ~0, so no CG progress is +/// thrown away, while a freshly-added row that `x` doesn't yet satisfy (or a +/// warm-started `x` from ADMM in `solve_hybrid`, which only satisfies bounds +/// approximately) gets pulled onto the constraint exactly. +fn active_set_restore_feasibility( + interp: &FiniteDifferenceInterpolator, + active: &[ActiveRow], + chol: &[f64], + x: &mut [f64], +) { + let w = active.len(); + if w == 0 { + return; + } + let residual: Vec = active + .iter() + .map(|a| interp.inequality_row_dot(a.row, x) - a.bound) + .collect(); + let y = cholesky_solve(chol, w, &residual); + for (a, yi) in active.iter().zip(y.iter()) { + interp.inequality_row_scatter_add(a.row, -*yi, x); + } +} + +#[inline] +fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] { + [ + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0], + ] +} + +#[inline] +fn norm3(v: [f64; 3]) -> f64 { + (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt() +} + +#[inline] +fn scale3(v: [f64; 3], s: f64) -> [f64; 3] { + [v[0] * s, v[1] * s, v[2] * s] +} + +/// Build the strike/dip basis `(u, v)` for unit vector `n`. It follows the +/// Python `loop_common.math.get_vectors` convention: the strike `u` is +/// horizontal, `u = (n_y, -n_x, 0) / hypot(n_x, n_y)`. For an exactly +/// vertical normal (`hypot == 0`) Python uses `atan2(0, 0) = 0`, so +/// `u = (0, -1, 0)`. The dip is `v = normalise(cross(u, n))`. +fn orthonormal_basis(n: [f64; 3]) -> ([f64; 3], [f64; 3]) { + let len = norm3(n); + let n = if len > 0.0 { scale3(n, 1.0 / len) } else { n }; + let h = n[0].hypot(n[1]); + let u = if h > 0.0 { + [n[1] / h, -n[0] / h, 0.0] + } else { + [0.0, -1.0, 0.0] + }; + let v_raw = cross(u, n); + let v_norm = norm3(v_raw); + let v = if v_norm > 0.0 { + scale3(v_raw, 1.0 / v_norm) + } else { + v_raw + }; + (u, v) +} + +#[derive(Debug, Clone, Copy)] +pub struct RegularisationWeights { + pub dxx: f64, + pub dyy: f64, + pub dzz: f64, + pub dxy: f64, + pub dxz: f64, + pub dyz: f64, +} + +impl Default for RegularisationWeights { + fn default() -> Self { + Self { + dxx: 1.0, + dyy: 1.0, + dzz: 1.0, + dxy: 1.0, + dxz: 1.0, + dyz: 1.0, + } + } +} + +/// Border (centred second-derivative `[1, -2, 1]`) regularisation weights, +/// one per axis, matching the Python `interpolation_weights["dx"/"dy"/"dz"]`. +#[derive(Debug, Clone, Copy)] +pub struct BorderRegularisationWeights { + pub dx: f64, + pub dy: f64, + pub dz: f64, +} + +impl Default for BorderRegularisationWeights { + fn default() -> Self { + Self { + dx: 1.0, + dy: 1.0, + dz: 1.0, + } + } +} + +fn build_border_stencils( + grid: &StructuredGrid3D, + weights: BorderRegularisationWeights, +) -> Vec<(EdgeStencil, f64)> { + let axis_weights = [weights.dx, weights.dy, weights.dz]; + let mut stencils = Vec::new(); + #[allow(clippy::needless_range_loop)] + for axis in 0..3 { + let w = axis_weights[axis]; + if let Some(s) = edge_forward(axis, &grid.nsteps) { + stencils.push((s, w)); + } + if let Some(s) = edge_backward(axis, &grid.nsteps) { + stencils.push((s, w)); + } + } + stencils +} + +/// One entry of the fused six-family regularisation kernel: `flat_offset` is +/// the tap's grid-step displacement (each component in `-2..=2`) +/// pre-converted to a flat (F-order) index delta for this grid shape (i.e. +/// `e.0 + nx*e.1 + nx*ny*e.2`, computed once when the kernel is built rather +/// than recomputed for every node `fused_interior_regularisation_into` +/// visits), and the paired `f64` is `sum_family w_family^2 * sum_{d1,d2 in +/// family taps: d2-d1==offset} coeff[d1]*coeff[d2]` -- the combined `S^T S` +/// autocorrelation kernel described in the module docs / `TASKS.md`'s "fused +/// single-kernel regularisation" item. +type KernelTap = (i64, f64); + +/// Minimum grid-step distance to the nearest *true* domain edge along any +/// axis (0 for the outermost node layer, 1 for the next layer in, ...). +/// Composing two radius-1 stencils (forward then adjoint) is only a +/// translation-invariant radius-2 stencil away from the boundary; see +/// `fused_interior_regularisation_into`'s doc comment for the full argument. +#[inline] +fn dist_to_true_edge(nsteps: &[usize; 3], i: usize, j: usize, k: usize) -> usize { + let dx = i.min(nsteps[0] - 1 - i); + let dy = j.min(nsteps[1] - 1 - j); + let dz = k.min(nsteps[2] - 1 - k); + dx.min(dy).min(dz) +} + +/// Precompute, once per grid shape (independent of regularisation weights): +/// - `shell_row_indices`: flat indices of every node at distance <= 2 from +/// the true edge -- the candidate stencil *row centres* whose forward +/// value can ever contribute to a boundary-shell output node once both a +/// forward and an adjoint radius-1 step are applied (see +/// `shell_regularisation_correction_into`). +/// - `shell_output_indices`: flat indices at distance < 2 -- the output +/// nodes `fused_interior_regularisation_into` deliberately skips and that +/// `shell_regularisation_correction_into` must fix up exactly. +/// - the former's flat-index inverse (`flat -> position`, `u32::MAX` for +/// non-shell-row nodes), so the adjoint half of the shell correction can +/// find a row's forward value by flat index in O(1). +/// +/// All three tables hold `u32` values to use 4 bytes per entry. The top +/// value `u32::MAX` is the empty marker. +fn build_shell_indices( + grid: &StructuredGrid3D, + tracker: &mut MemTracker, +) -> Result { + let dof = grid.n_nodes(); + assert!( + dof < u32::MAX as usize, + "grid has too many nodes for the u32 shell tables" + ); + let mut shell_rows = Vec::new(); + let mut shell_outputs = Vec::new(); + for flat in 0..dof { + let (i, j, k) = grid.node_ijk(flat); + let d = dist_to_true_edge(&grid.nsteps, i, j, k); + if d <= 2 { + shell_rows.push(flat as u32); + } + if d < 2 { + shell_outputs.push(flat as u32); + } + } + // The lookup is the only table of size dof. The tracker counts it. + let mut lookup: Vec = tracker.try_zeroed(dof)?; + lookup.fill(u32::MAX); + for (pos, &flat) in shell_rows.iter().enumerate() { + lookup[flat as usize] = pos as u32; + } + Ok(ShellTables { + rows: shell_rows, + outputs: shell_outputs, + lookup, + }) +} + +/// The three shell tables (see `build_shell_indices`). +struct ShellTables { + /// Flat indices of the nodes at distance <= 2 from the true edge. + rows: Vec, + /// Flat indices of the nodes at distance < 2. + outputs: Vec, + /// Inverse of `rows`: flat index to position, `u32::MAX` if not a row. + lookup: Vec, +} + +/// Build the fused single-kernel regularisation's combined (5,5,5)-radius +/// autocorrelation kernel from the six curvature families' (already +/// unit-L2-normalised) taps and their scalar weights (see module docs). +/// Every one of the six families always qualifies for fusion in this crate +/// (unlike the Python original, which also has to guard against +/// spatially-varying per-dof weights and caller-supplied custom masks -- +/// neither exists here: `RegularisationWeights` is always one scalar per +/// family and the six stencils are always the fixed canonical shapes from +/// `stencil::{dxx, dyy, dzz, dxy, dxz, dyz}`), so this simply sums every +/// family's contribution unconditionally (skipping any with zero weight, +/// which would contribute nothing anyway). +fn build_combined_kernel( + stencils: &[(Stencil, f64); 6], + grid: &StructuredGrid3D, +) -> Vec { + let mut map: HashMap<(i32, i32, i32), f64> = HashMap::new(); + for (stencil, weight) in stencils { + if *weight == 0.0 { + continue; + } + let w2 = weight * weight; + for (d1, c1) in stencil.offsets.iter().zip(stencil.coeffs.iter()) { + for (d2, c2) in stencil.offsets.iter().zip(stencil.coeffs.iter()) { + let e = (d2.0 - d1.0, d2.1 - d1.1, d2.2 - d1.2); + *map.entry(e).or_insert(0.0) += w2 * c1 * c2; + } + } + } + // `HashMap`'s iteration order is randomised per-instance (a fresh random + // hasher seed per `HashMap::new()`), so two structurally-identical + // interpolators (same grid shape, same weights) could otherwise end up + // with their combined kernel taps in a different order -- and since + // float addition isn't associative, summing the *same* taps in a + // *different* order in `fused_interior_regularisation_into` can differ + // by a ULP or two between the two instances. Sorting by offset makes + // the kernel's (and therefore every downstream sum's) order a pure + // function of the stencils/weights, restoring the bit-for-bit + // reproducibility every other part of this crate already assumes + // between structurally-identical interpolators. + let mut offsets: Vec<((i32, i32, i32), f64)> = + map.into_iter().filter(|(_, v)| v.abs() > 1e-15).collect(); + offsets.sort_by_key(|(e, _)| *e); + // Pre-convert each (i32,i32,i32) offset to a flat index delta for this + // grid shape once, here, rather than recomputing `nx*e.1 + nx*ny*e.2` + // for every (node, tap) pair inside `fused_interior_regularisation_into`'s + // hot loop -- this was previously the dominant cost of the fused pass, + // since it re-did the same two multiplies per tap on every single node + // visited instead of once per tap ever. + let nx_i = grid.nsteps[0] as i64; + let ny_i = grid.nsteps[1] as i64; + offsets + .into_iter() + .map(|(e, c)| (e.0 as i64 + nx_i * e.1 as i64 + nx_i * ny_i * e.2 as i64, c)) + .collect() +} + +/// Look up a stencil's normalised coefficient at a specific tap offset (0 if +/// the stencil has no tap there). Only used to build the `BlockJacobiX` +/// preconditioner's off-diagonal (see `dxx_and_border_dx_offdiagonal`), +/// where we need specific named taps rather than an iteration over all of +/// them. +fn coeff_at(stencil: &Stencil, offset: (i32, i32, i32)) -> f64 { + stencil + .offsets + .iter() + .zip(stencil.coeffs.iter()) + .find(|(o, _)| **o == offset) + .map(|(_, c)| *c) + .unwrap_or(0.0) +} + +/// Solve the symmetric tridiagonal system `diag[i] z[i] + off[i-1] z[i-1] + +/// off[i] z[i+1] = r[i]` (`off[n-1]` is never read) via the Thomas +/// algorithm. `cprime` is caller-supplied scratch (length `n`) so this makes +/// no allocation of its own -- needed since it runs inside the CG hot loop +/// for `PreconditionerKind::BlockJacobiX`. A near-zero pivot falls back to +/// leaving that entry's contribution as if the local block were diagonal +/// there (mirroring the plain Jacobi preconditioner's `if di > 0.0 {..} else +/// {..}` fallback) rather than dividing by (near) zero; should not trigger +/// in practice given `ridge_factor > 0` (see `dxx_and_border_dx_offdiagonal`'s +/// docs for why that guarantees a strictly positive definite block). +fn solve_tridiagonal_line(diag: &[f64], off: &[f64], r: &[f64], cprime: &mut [f64], z: &mut [f64]) { + let n = diag.len(); + if n == 0 { + return; + } + if n == 1 { + z[0] = if diag[0] > 0.0 { r[0] / diag[0] } else { r[0] }; + return; + } + let mut denom = diag[0]; + if denom.abs() < 1e-300 { + denom = 1.0; + } + cprime[0] = off[0] / denom; + z[0] = r[0] / denom; + for i in 1..n { + let e_prev = off[i - 1]; + let mut m = diag[i] - e_prev * cprime[i - 1]; + if m.abs() < 1e-300 { + m = 1e-300; + } + let e_i = if i < n - 1 { off[i] } else { 0.0 }; + cprime[i] = e_i / m; + z[i] = (r[i] - e_prev * z[i - 1]) / m; + } + for i in (0..n - 1).rev() { + z[i] -= cprime[i] * z[i + 1]; + } +} + +/// Which preconditioner [`FiniteDifferenceInterpolator::solve_with`] uses. +/// `Jacobi` is the original plain-diagonal preconditioner (cheap, always +/// applicable, kept as the default so existing callers are unaffected). +/// `BlockJacobiX` solves a small tridiagonal system per x-axis grid line, +/// exactly capturing the `dxx` family's and any x-axis border +/// regularisation's line-local coupling instead of discarding it to the +/// diagonal -- see `dxx_and_border_dx_offdiagonal` for the SPD argument. +/// It degenerates to plain Jacobi's tridiagonal-with-zero-off-diagonal +/// special case whenever there's no such coupling (e.g. `dxx` weight is +/// zero and no x-axis border regularisation is configured), so it is never +/// worse than Jacobi in that sense, only ever adds real information. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum PreconditionerKind { + #[default] + Jacobi, + BlockJacobiX, +} + +/// Result of [`FiniteDifferenceInterpolator::solve_admm`]. +pub struct AdmmResult { + pub x: Vec, + /// Number of outer ADMM iterations run. `0` when there were no + /// inequality constraints at all (the plain-CG-delegating fast path). + pub outer_iterations: usize, + /// Whether the outer primal/dual residual stopping criterion was met + /// before `max_outer_iter` outer iterations (or, in the no-inequality + /// fast path, whether the delegated equality CG solve converged). + pub converged: bool, + /// Sum of every outer iteration's inner-CG iteration count (a rough + /// total-work indicator; not itself a convergence signal). + pub inner_iterations_total: usize, +} + +/// Result of [`FiniteDifferenceInterpolator::solve_active_set_cg`] (and the +/// active-set-polish stage of [`solve_hybrid`](FiniteDifferenceInterpolator::solve_hybrid)). +pub struct ActiveSetCgResult { + pub x: Vec, + /// Number of working-set changes (a row joining or leaving the active + /// set), each one restarting the projected-CG inner solve on the + /// updated working set. `0` when there were no inequality constraints + /// at all (the plain-CG-delegating fast path). + pub outer_iterations: usize, + /// Sum of every working-set stage's inner (projected) CG iteration + /// count (a rough total-work indicator, not itself a convergence + /// signal). + pub inner_iterations_total: usize, + /// Whether the KKT optimality conditions were met (projected residual + /// below `inner_tol` *and* no active row left with a wrong-signed + /// Lagrange multiplier) before `max_outer_iter` working-set changes. + pub converged: bool, +} + +/// Result of [`FiniteDifferenceInterpolator::solve_hybrid`]. +pub struct HybridResult { + pub x: Vec, + /// Number of ADMM warm-up outer iterations run (always exactly the + /// caller-supplied `admm_warmup_iter`, since the warm-up stage runs a + /// fixed budget rather than iterating to convergence). + pub admm_warmup_iterations: usize, + /// Sum of the ADMM warm-up stage's inner-CG iteration counts. + pub admm_inner_iterations_total: usize, + /// The active-set polish stage's `outer_iterations` (see + /// [`ActiveSetCgResult::outer_iterations`]). + pub polish_outer_iterations: usize, + /// The active-set polish stage's `inner_iterations_total` (see + /// [`ActiveSetCgResult::inner_iterations_total`]). + pub polish_inner_iterations_total: usize, + /// Whether the active-set polish stage converged (see + /// [`ActiveSetCgResult::converged`]). + pub converged: bool, +} + +pub struct FiniteDifferenceInterpolator { + pub grid: StructuredGrid3D, + pub regularisation: RegularisationWeights, + pub border_regularisation: BorderRegularisationWeights, + /// Diagonal Tikhonov damping added to the normal equations so the + /// system stays positive definite even with sparse/degenerate data + /// constraints, matching `DiscreteInterpolator.ridge_factor`. + pub ridge_factor: f64, + value_constraints: Vec, + gradient_constraints: Vec, + norm_constraints: Vec, + tangent_constraints: Vec, + interface_constraints: Vec, + /// Value-bound inequality rows (see `add_value_inequality_constraint`); + /// row order here, followed by `pair_inequality_constraints`, is the + /// fixed order `solve_admm`'s `Q`/`z`/`u` use. + value_inequality_constraints: Vec, + /// Pairwise-ordering inequality rows (see `add_inequality_pair_constraint`). + pair_inequality_constraints: Vec, + stencils: [(Stencil, f64); 6], + border_stencils: Vec<(EdgeStencil, f64)>, + /// Fused single-kernel regularisation's combined kernel (see + /// `build_combined_kernel`/`fused_interior_regularisation_into`); + /// rebuilt whenever `stencils`'/weights change (`with_regularisation`). + /// The table is empty until `prepare` (or the first use) builds it. + combined_kernel: OnceLock>, + /// Row centres (dist to true edge <= 2) / output nodes (dist < 2) / the + /// former's flat-index inverse -- grid-shape-only, built once (see + /// `build_shell_indices`). The tables are empty until `prepare` (or the + /// first use) builds them. `new` does not allocate them. + shell: OnceLock, + /// The 343 per-class regularisation kernels (see `class_stencil.rs`). + /// `None` inside when the grid has an axis with fewer than 7 nodes. + /// Built on first use; rebuilt when the weights change. + class_kernels: OnceLock>, + /// Per-node squared weight for each of the six curvature families + /// (`[dxx, dyy, dzz, dxy, dxz, dyz]`), accumulated across every + /// [`add_directional_regularisation`](Self::add_directional_regularisation) + /// call. `None` until the first such call (lazily allocated so grids that + /// never use directional regularisation -- i.e. every caller except fold + /// interpolation -- pay no extra memory or per-iteration cost). Unlike + /// `stencils`' uniform scalar weights, these vary per grid node, so they + /// cannot go through the fused single-kernel fast path (which requires a + /// uniform weight per family -- see `build_combined_kernel`'s docs) and + /// are instead applied via their own unfused pass, the same way + /// `border_stencils` are. + directional_weight2: Option<[Vec; 6]>, +} + +impl FiniteDifferenceInterpolator { + pub fn new(grid: StructuredGrid3D) -> Self { + let regularisation = RegularisationWeights::default(); + let border_regularisation = BorderRegularisationWeights::default(); + let stencils = [ + (dxx(), regularisation.dxx), + (dyy(), regularisation.dyy), + (dzz(), regularisation.dzz), + (dxy(), regularisation.dxy), + (dxz(), regularisation.dxz), + (dyz(), regularisation.dyz), + ]; + let border_stencils = build_border_stencils(&grid, border_regularisation); + Self { + grid, + regularisation, + border_regularisation, + ridge_factor: 1e-8, + value_constraints: Vec::new(), + gradient_constraints: Vec::new(), + norm_constraints: Vec::new(), + tangent_constraints: Vec::new(), + interface_constraints: Vec::new(), + value_inequality_constraints: Vec::new(), + pair_inequality_constraints: Vec::new(), + stencils, + border_stencils, + combined_kernel: OnceLock::new(), + shell: OnceLock::new(), + class_kernels: OnceLock::new(), + directional_weight2: None, + } + } + + pub fn with_regularisation(mut self, weights: RegularisationWeights) -> Self { + self.regularisation = weights; + self.stencils = [ + (dxx(), weights.dxx), + (dyy(), weights.dyy), + (dzz(), weights.dzz), + (dxy(), weights.dxy), + (dxz(), weights.dxz), + (dyz(), weights.dyz), + ]; + // The weights changed, so the old kernels are not valid. Drop them. + self.combined_kernel = OnceLock::new(); + self.class_kernels = OnceLock::new(); + self + } + + /// Build the tables of the regularisation operator: the class kernels, + /// or (for a grid with an axis of fewer than 7 nodes, or with + /// directional regularisation) the shell tables and the combined + /// kernel. Call it once before a solve. A second call does nothing. + /// The grid-sized shell lookup is counted by `tracker`. + #[allow(dead_code)] + pub(crate) fn prepare(&mut self, tracker: &mut MemTracker) -> Result<(), CoreError> { + self.ensure_tables(tracker) + } + + /// As `prepare`, but it takes `&self`. The tables use `OnceLock`. + fn ensure_tables(&self, tracker: &mut MemTracker) -> Result<(), CoreError> { + if self.class_kernels().is_some() { + return Ok(()); + } + if self.shell.get().is_none() { + let tables = build_shell_indices(&self.grid, tracker)?; + // If another thread set the tables first, keep that value. + let _ = self.shell.set(tables); + } + self.kernel(); + Ok(()) + } + + /// The shell tables. Built without a budget if `prepare` did not run. + fn shell(&self) -> &ShellTables { + self.shell.get_or_init(|| { + build_shell_indices(&self.grid, &mut MemTracker::new(None)) + .expect("shell tables must fit in memory") + }) + } + + /// The combined kernel. Built on first use. + fn kernel(&self) -> &[KernelTap] { + self.combined_kernel + .get_or_init(|| build_combined_kernel(&self.stencils, &self.grid)) + } + + /// The class kernels, if the operator can use them: every axis has at + /// least 7 nodes and there is no directional regularisation. Built on + /// first use. + fn class_kernels(&self) -> Option<&ClassKernels> { + if self.directional_weight2.is_some() { + return None; + } + self.class_kernels + .get_or_init(|| ClassKernels::build(&self.grid, &self.stencils, &self.border_stencils)) + .as_ref() + } + + pub fn with_border_regularisation(mut self, weights: BorderRegularisationWeights) -> Self { + self.border_regularisation = weights; + self.border_stencils = build_border_stencils(&self.grid, weights); + self.class_kernels = OnceLock::new(); + self + } + + pub fn dof(&self) -> usize { + self.grid.n_nodes() + } + + /// Bytes held by the interpolator's own tables: the shell lookup, the + /// shell index lists, the directional weights (if any) and the combined + /// kernel. It counts the capacity of each table. It does not count the + /// constraint records or the vectors made during a solve. + pub fn table_bytes(&self) -> usize { + let directional = self + .directional_weight2 + .as_ref() + .map_or(0, |w| w.iter().map(|v| v.capacity()).sum::()) + * std::mem::size_of::(); + // Before `prepare` the tables are not built, so they count 0 bytes. + let shell = self.shell.get().map_or(0, |s| { + (s.lookup.capacity() + s.rows.capacity() + s.outputs.capacity()) + * std::mem::size_of::() + }); + let kernel = self + .combined_kernel + .get() + .map_or(0, |k| k.capacity() * std::mem::size_of::()); + let class = self + .class_kernels + .get() + .and_then(|k| k.as_ref()) + .map_or(0, ClassKernels::bytes); + shell + directional + kernel + class + } + + /// Add a scalar value observation at `point` with weight `weight`. + /// Points outside the grid's bounding box are silently dropped (matching + /// the Python interpolator's `inside` masking). + pub fn add_value_constraint(&mut self, point: [f64; 3], value: f64, weight: f64) { + let Some((indices, raw_coefs)) = self.grid.trilinear_coeffs(point) else { + return; + }; + let norm = raw_coefs.iter().map(|c| c * c).sum::().sqrt(); + if norm <= 0.0 { + return; + } + let coefs = raw_coefs.map(|c| c / norm); + self.value_constraints.push(PointConstraint { + indices, + coefs, + value: value / norm, + weight, + }); + } + + pub fn n_value_constraints(&self) -> usize { + self.value_constraints.len() + } + + /// Shared machinery for gradient-orthogonal constraints (`strike . grad + /// f = 0`, `dip . grad f = 0`, from `add_gradient_constraint`) and + /// tangent constraints (`v . grad f = 0`, from `add_tangent_constraint`): + /// both reduce to "one row constraining the field gradient at `point` to + /// be orthogonal to `vector`", i.e. the Python + /// `add_gradient_orthogonal_constraints`. `vector` is given in world + /// space and normalised before being dotted with the trilinear gradient + /// shape function; `None` if `point` is outside the grid or `vector` is + /// (numerically) zero. + fn build_orthogonal_constraint( + &self, + point: [f64; 3], + vector: [f64; 3], + weight: f64, + ) -> Option { + let (indices, grad) = self.grid.trilinear_gradient(point)?; + let v_local = vector; + let v_norm = norm3(v_local); + if v_norm <= 0.0 { + return None; + } + let v_unit = scale3(v_local, 1.0 / v_norm); + let mut raw_coefs = [0.0; 8]; + for c in 0..8 { + raw_coefs[c] = v_unit[0] * grad[c][0] + v_unit[1] * grad[c][1] + v_unit[2] * grad[c][2]; + } + let row_norm = raw_coefs.iter().map(|c| c * c).sum::().sqrt(); + if row_norm <= 0.0 { + return None; + } + let coefs = raw_coefs.map(|c| c / row_norm); + Some(PointConstraint { + indices, + coefs, + value: 0.0, + weight, + }) + } + + /// Add an orientation-data (gradient) constraint at `point`: `normal` is + /// a (not necessarily unit) vector giving the target gradient + /// *direction* only (its magnitude is discarded -- use + /// [`add_norm_constraint`](Self::add_norm_constraint) to also constrain + /// magnitude). Internally split into two orthogonal directions spanning + /// the plane perpendicular to `normal` (see + /// [`orthonormal_basis`]/Python's `get_vectors` strike/dip split), each + /// constrained to be orthogonal to the field gradient at `point`. Points + /// outside the grid, or a zero `normal`, are silently dropped. + pub fn add_gradient_constraint(&mut self, point: [f64; 3], normal: [f64; 3], weight: f64) { + let n_norm = norm3(normal); + if n_norm <= 0.0 { + return; + } + let unit = scale3(normal, 1.0 / n_norm); + let (u, v) = orthonormal_basis(unit); + if let Some(c) = self.build_orthogonal_constraint(point, u, weight) { + self.gradient_constraints.push(c); + } + if let Some(c) = self.build_orthogonal_constraint(point, v, weight) { + self.gradient_constraints.push(c); + } + } + + pub fn n_gradient_constraints(&self) -> usize { + self.gradient_constraints.len() + } + + /// Add a tangent constraint at `point`: constrains the field gradient to + /// be orthogonal to the given (arbitrary, not necessarily unit) `vector` + /// -- a single row, unlike [`add_gradient_constraint`](Self::add_gradient_constraint)'s + /// two (strike/dip) rows. Points outside the grid, or a zero `vector`, + /// are silently dropped. + pub fn add_tangent_constraint(&mut self, point: [f64; 3], vector: [f64; 3], weight: f64) { + if let Some(c) = self.build_orthogonal_constraint(point, vector, weight) { + self.tangent_constraints.push(c); + } + } + + pub fn n_tangent_constraints(&self) -> usize { + self.tangent_constraints.len() + } + + /// Add a norm constraint at `point`: constrains each of the 3 gradient + /// components directly to the corresponding component of + /// `target_gradient` (given in world space), one row per axis. Unlike + /// [`add_gradient_constraint`](Self::add_gradient_constraint), this is + /// non-homogeneous (targets an actual value, not just "= 0"), so it + /// constrains gradient *magnitude* as well as direction. Points outside + /// the grid are silently dropped. + pub fn add_norm_constraint(&mut self, point: [f64; 3], target_gradient: [f64; 3], weight: f64) { + let Some((indices, grad)) = self.grid.trilinear_gradient(point) else { + return; + }; + let target_local = target_gradient; + for axis in 0..3 { + let mut raw_coefs = [0.0; 8]; + for c in 0..8 { + raw_coefs[c] = grad[c][axis]; + } + let row_norm = raw_coefs.iter().map(|c| c * c).sum::().sqrt(); + if row_norm <= 0.0 { + continue; + } + let coefs = raw_coefs.map(|c| c / row_norm); + let value = target_local[axis] / row_norm; + self.norm_constraints.push(PointConstraint { + indices, + coefs, + value, + weight, + }); + } + } + + pub fn n_norm_constraints(&self) -> usize { + self.norm_constraints.len() + } + + /// Add interface (equal-value) constraints: every point in `points` + /// sharing the same `id` (compared by bit-exact equality, matching + /// `np.unique`-based grouping on a float id column) is pairwise + /// constrained to have equal field value. Uses the "star" pairing + /// strategy (mirroring the Python `_interface_pair_indices`'s minimal + /// default) -- every point in a group is paired with the group's first + /// point, rather than the full `all`/`chain` switching + /// `interface_pair_mode` supports. Points outside the grid, `NaN` ids, + /// and groups of size < 2 are silently dropped. + pub fn add_interface_constraints(&mut self, points: &[[f64; 3]], ids: &[f64], weight: f64) { + assert_eq!( + points.len(), + ids.len(), + "points and ids must be the same length" + ); + let mut groups: HashMap> = HashMap::new(); + for (i, &id) in ids.iter().enumerate() { + if id.is_nan() { + continue; + } + groups.entry(id.to_bits()).or_default().push(i); + } + for idxs in groups.values() { + if idxs.len() < 2 { + continue; + } + let first = idxs[0]; + let Some((idx_a, coefs_a)) = self.grid.trilinear_coeffs(points[first]) else { + continue; + }; + for &j in &idxs[1..] { + let Some((idx_b, coefs_b)) = self.grid.trilinear_coeffs(points[j]) else { + continue; + }; + let mut indices = [0usize; 16]; + let mut coefs = [0.0f64; 16]; + indices[..8].copy_from_slice(&idx_a); + indices[8..].copy_from_slice(&idx_b); + for k in 0..8 { + coefs[k] = coefs_a[k]; + coefs[8 + k] = -coefs_b[k]; + } + let norm = coefs.iter().map(|c| c * c).sum::().sqrt(); + if norm <= 0.0 { + continue; + } + for c in coefs.iter_mut() { + *c /= norm; + } + self.interface_constraints.push(PairConstraint { + indices, + coefs, + weight, + }); + } + } + } + + pub fn n_interface_constraints(&self) -> usize { + self.interface_constraints.len() + } + + /// Add an anisotropic directional-regularisation term: penalises the + /// directional second derivative `(v.grad)^2 f = 0` at every interior + /// grid node, for a direction field `v` given one (world-space) vector + /// per grid node in `directions` (length must equal + /// [`dof`](Self::dof) -- mismatched lengths are silently dropped, + /// matching every other constraint family's out-of-grid drop + /// convention). This is the generic primitive fold interpolation's + /// anisotropic smoothing is built from (see the Python + /// `FiniteDifferenceInterpolator.minimise_directional_gradient_change`): + /// callers evaluate their own directional geometry (e.g. a fold frame's + /// axis/normal/deformed-orientation directions) and pass the resulting + /// per-node vectors in here. + /// + /// Expanding `(v.grad)^2 f` in the six second-derivative families gives + /// `vx^2 f_xx + vy^2 f_yy + vz^2 f_zz + 2vx*vy f_xy + 2vx*vz f_xz + 2vy*vz + /// f_yz`, so each family stays independent (no cross-family terms in the + /// squared residual) -- only its *weight* becomes per-node instead of one + /// uniform scalar. Multiple calls (e.g. fold's three orthogonal + /// directions, each with its own weight) accumulate independently: this + /// method adds `(weight * comp_w_family(node))^2` to that family's + /// running per-node squared-weight total, exactly as if each call were + /// its own independent least-squares row set. + /// + /// `directions` are used raw (not renormalised), matching the Python + /// original. + /// + /// Unlike the uniform-weight `stencils`, these per-node weights cannot + /// use the fused single-kernel regularisation fast path (see + /// `build_combined_kernel`'s docs), so they are accumulated into the + /// normal operator via their own unfused pass -- see + /// `apply_normal_operator_into`. + pub fn add_directional_regularisation(&mut self, directions: &[[f64; 3]], weight: f64) { + if directions.len() != self.dof() { + return; + } + let dof = self.dof(); + let weight2 = self + .directional_weight2 + .get_or_insert_with(|| std::array::from_fn(|_| vec![0.0; dof])); + for (c, dir) in directions.iter().enumerate() { + let [vx, vy, vz] = *dir; + let comp_w = [ + vx * vx, + vy * vy, + vz * vz, + 2.0 * vx * vy, + 2.0 * vx * vz, + 2.0 * vy * vz, + ]; + for family in 0..6 { + let cw = weight * comp_w[family]; + weight2[family][c] += cw * cw; + } + } + } + + /// Add a value-bound inequality constraint at `point`: constrains the + /// predicted trilinear value there to lie in `[lower, upper]` (instead + /// of an equality target), weighted by `weight`. Only used by + /// [`solve_admm`](Self::solve_admm) -- [`solve`](Self::solve)/ + /// [`solve_with`](Self::solve_with) never look at inequality rows at + /// all, so adding one has no effect unless you solve with `solve_admm`. + /// + /// Points outside the grid are silently dropped, matching every other + /// constraint family. A non-positive `weight` is also silently dropped: + /// unlike the equality families (which always use `weight^2` and so + /// don't care about its sign), scaling an inequality row's `coefs` *and* + /// its `lower`/`upper` bounds by the same factor only preserves the + /// constraint's meaning if that factor is strictly positive. + pub fn add_value_inequality_constraint( + &mut self, + point: [f64; 3], + lower: f64, + upper: f64, + weight: f64, + ) { + if weight <= 0.0 { + return; + } + let Some((indices, raw_coefs)) = self.grid.trilinear_coeffs(point) else { + return; + }; + let norm = raw_coefs.iter().map(|c| c * c).sum::().sqrt(); + if norm <= 0.0 { + return; + } + let scale = weight / norm; + let coefs = raw_coefs.map(|c| c * scale); + self.value_inequality_constraints + .push(InequalityPointConstraint { + indices, + coefs, + lower: lower * scale, + upper: upper * scale, + }); + } + + pub fn n_value_inequality_constraints(&self) -> usize { + self.value_inequality_constraints.len() + } + + /// Add a pairwise-ordering inequality constraint: constrains the + /// difference between the predicted trilinear values at `point_a` and + /// `point_b` (`f(point_a) - f(point_b)`) to lie in `[lower, upper]`. + /// This is the stratigraphic-ordering case -- e.g. `lower = -inf`, + /// `upper = 0` encodes "`point_a`'s unit must not be above `point_b`'s", + /// i.e. `f(point_a) <= f(point_b)`. Only used by + /// [`solve_admm`](Self::solve_admm); see + /// [`add_value_inequality_constraint`](Self::add_value_inequality_constraint)'s + /// docs for the drop conditions (either point outside the grid, or + /// non-positive `weight`), which apply identically here. + pub fn add_inequality_pair_constraint( + &mut self, + point_a: [f64; 3], + point_b: [f64; 3], + lower: f64, + upper: f64, + weight: f64, + ) { + if weight <= 0.0 { + return; + } + let Some((idx_a, coefs_a)) = self.grid.trilinear_coeffs(point_a) else { + return; + }; + let Some((idx_b, coefs_b)) = self.grid.trilinear_coeffs(point_b) else { + return; + }; + let mut indices = [0usize; 16]; + let mut coefs = [0.0f64; 16]; + indices[..8].copy_from_slice(&idx_a); + indices[8..].copy_from_slice(&idx_b); + for k in 0..8 { + coefs[k] = coefs_a[k]; + coefs[8 + k] = -coefs_b[k]; + } + let norm = coefs.iter().map(|c| c * c).sum::().sqrt(); + if norm <= 0.0 { + return; + } + let scale = weight / norm; + for c in coefs.iter_mut() { + *c *= scale; + } + self.pair_inequality_constraints + .push(InequalityPairConstraint { + indices, + coefs, + lower: lower * scale, + upper: upper * scale, + }); + } + + pub fn n_inequality_pair_constraints(&self) -> usize { + self.pair_inequality_constraints.len() + } + + /// Total inequality-constraint row count (value-bound + pairwise-ordering). + pub fn n_inequality_constraints(&self) -> usize { + self.value_inequality_constraints.len() + self.pair_inequality_constraints.len() + } + + /// `Q x`: one entry per inequality-constraint row, in the fixed order + /// value-bound rows (in add order) then pairwise-ordering rows (in add + /// order) -- the same order [`inequality_bounds`](Self::inequality_bounds) + /// and `solve_admm`'s internal `z`/`u` ADMM state vectors use. + pub fn apply_inequality_operator(&self, x: &[S]) -> Vec { + let mut out = Vec::with_capacity(self.n_inequality_constraints()); + for c in &self.value_inequality_constraints { + out.push(ineq_point_row_value(c, x)); + } + for c in &self.pair_inequality_constraints { + out.push(ineq_pair_row_value(c, x)); + } + out + } + + /// `(lower, upper)` bound vectors, in the same row order as + /// [`apply_inequality_operator`](Self::apply_inequality_operator). + pub fn inequality_bounds(&self) -> (Vec, Vec) { + let n = self.n_inequality_constraints(); + let mut lower = Vec::with_capacity(n); + let mut upper = Vec::with_capacity(n); + for c in &self.value_inequality_constraints { + lower.push(c.lower); + upper.push(c.upper); + } + for c in &self.pair_inequality_constraints { + lower.push(c.lower); + upper.push(c.upper); + } + (lower, upper) + } + + /// Per row: the factor that turns a row violation into field units. + /// A row is stored with `scale = weight / norm`. The first 8 coefficients + /// of every row are trilinear weights times `scale`, and trilinear weights + /// sum to 1. So the sum of the first 8 coefficients is `scale`. The + /// result is `1 / scale`. Row order is the same as `inequality_bounds`. + pub(crate) fn inequality_inverse_scales(&self) -> Vec { + let inv = |first8: &[f64]| { + let s: f64 = first8.iter().sum(); + if s > 0.0 { 1.0 / s } else { 1.0 } + }; + let mut out = Vec::with_capacity(self.n_inequality_constraints()); + for c in &self.value_inequality_constraints { + out.push(inv(&c.coefs[..8])); + } + for c in &self.pair_inequality_constraints { + out.push(inv(&c.coefs[..8])); + } + out + } + + /// `out += rho * Q^T y`, `y` given in the same row order as + /// [`apply_inequality_operator`](Self::apply_inequality_operator). Used + /// for `solve_admm`'s x-update rhs term `rho * Q^T (z - u)`. + pub(crate) fn accumulate_inequality_transpose( + &self, + y: &[f64], + rho: f64, + out: &mut [S], + ) { + let mut idx = 0usize; + for c in &self.value_inequality_constraints { + let s = rho * y[idx]; + for k in 0..8 { + let o = &mut out[c.indices[k]]; + *o = S::from_f64(o.to_f64() + s * c.coefs[k]); + } + idx += 1; + } + for c in &self.pair_inequality_constraints { + let s = rho * y[idx]; + for k in 0..16 { + let o = &mut out[c.indices[k]]; + *o = S::from_f64(o.to_f64() + s * c.coefs[k]); + } + idx += 1; + } + } + + /// `out += rho * Q^T Q x`, accumulated directly (each row is a rank-1 + /// outer product `coefs (coefs . x[indices])`, so this needs no + /// intermediate `Q x` buffer) -- the inequality-constraint contribution + /// to `solve_admm`'s x-update operator `N + rho * Q^T Q`. + pub(crate) fn accumulate_inequality_normal(&self, x: &[S], rho: f64, out: &mut [S]) { + for c in &self.value_inequality_constraints { + let s = rho * ineq_point_row_value(c, x); + for k in 0..8 { + let o = &mut out[c.indices[k]]; + *o = S::from_f64(o.to_f64() + s * c.coefs[k]); + } + } + for c in &self.pair_inequality_constraints { + let s = rho * ineq_pair_row_value(c, x); + for k in 0..16 { + let o = &mut out[c.indices[k]]; + *o = S::from_f64(o.to_f64() + s * c.coefs[k]); + } + } + } + + /// `rho * diag(Q^T Q)`, for the ADMM inner CG solve's Jacobi preconditioner. + fn inequality_diagonal(&self, rho: f64) -> Vec { + let mut diag = vec![0.0; self.dof()]; + for c in &self.value_inequality_constraints { + for k in 0..8 { + diag[c.indices[k]] += rho * c.coefs[k] * c.coefs[k]; + } + } + for c in &self.pair_inequality_constraints { + for k in 0..16 { + diag[c.indices[k]] += rho * c.coefs[k] * c.coefs[k]; + } + } + diag + } + + /// `diag += rho * diag(Q^T Q)`. As `inequality_diagonal`, but it adds + /// into `diag` and allocates no new full-size vector. + pub(crate) fn add_inequality_diagonal(&self, rho: f64, diag: &mut [f64]) { + for c in &self.value_inequality_constraints { + for k in 0..8 { + diag[c.indices[k]] += rho * c.coefs[k] * c.coefs[k]; + } + } + for c in &self.pair_inequality_constraints { + for k in 0..16 { + diag[c.indices[k]] += rho * c.coefs[k] * c.coefs[k]; + } + } + } + + /// Row `k`'s `(indices, coefs)`, in the flattened value-then-pair order + /// every inequality-row accessor uses (see `apply_inequality_operator`). + /// Used by `active_set_cg_core`/the free `active_set_*` helpers, which + /// need to address one inequality row at a time rather than the whole + /// stack `Q` at once. + fn inequality_row(&self, k: usize) -> (&[usize], &[f64]) { + let nv = self.value_inequality_constraints.len(); + if k < nv { + let c = &self.value_inequality_constraints[k]; + (&c.indices, &c.coefs) + } else { + let c = &self.pair_inequality_constraints[k - nv]; + (&c.indices, &c.coefs) + } + } + + /// Row `k`'s value `Q_k . x`. + fn inequality_row_dot(&self, k: usize, x: &[f64]) -> f64 { + let (idx, coefs) = self.inequality_row(k); + idx.iter().zip(coefs.iter()).map(|(i, c)| c * x[*i]).sum() + } + + /// `out += scalar * Q_k^T`, i.e. scatter row `k`'s coefficients (scaled) + /// into `out` at its dof indices. + fn inequality_row_scatter_add(&self, k: usize, scalar: f64, out: &mut [f64]) { + let (idx, coefs) = self.inequality_row(k); + for (i, c) in idx.iter().zip(coefs.iter()) { + out[*i] += scalar * c; + } + } + + /// `Q_a . Q_b` (dot product of two inequality rows over their shared + /// dofs), for building the active working set's Gram matrix + /// (`active_set_gram`). + fn inequality_row_dot_rows(&self, a: usize, b: usize) -> f64 { + let (idx_a, coefs_a) = self.inequality_row(a); + let (idx_b, coefs_b) = self.inequality_row(b); + let mut v = 0.0; + for (ia, ca) in idx_a.iter().zip(coefs_a.iter()) { + for (ib, cb) in idx_b.iter().zip(coefs_b.iter()) { + if ia == ib { + v += ca * cb; + } + } + } + v + } + + /// As [`inequality_row_dot_rows`](Self::inequality_row_dot_rows), but in + /// the `diag(dinv)` inner product (`Q_a . diag(dinv) . Q_b`) -- used for + /// `active_set_gram_weighted`'s Jacobi-preconditioned projection. + fn inequality_row_dot_rows_weighted(&self, a: usize, b: usize, dinv: &[f64]) -> f64 { + let (idx_a, coefs_a) = self.inequality_row(a); + let (idx_b, coefs_b) = self.inequality_row(b); + let mut v = 0.0; + for (ia, ca) in idx_a.iter().zip(coefs_a.iter()) { + for (ib, cb) in idx_b.iter().zip(coefs_b.iter()) { + if ia == ib { + v += ca * cb * dinv[*ia]; + } + } + } + v + } + + /// `A_data^T W^2 A_data x`, accumulated directly from the stored + /// trilinear rows -- there are normally far fewer constraints than grid + /// nodes, so a plain scatter loop (no parallelism, no sparse matrix) is + /// both simplest and fastest here. + fn accumulate_data_normal(&self, x: &[S], out: &mut [S]) { + accumulate_point_constraints_normal(&self.value_constraints, x, out); + accumulate_point_constraints_normal(&self.gradient_constraints, x, out); + accumulate_point_constraints_normal(&self.norm_constraints, x, out); + accumulate_point_constraints_normal(&self.tangent_constraints, x, out); + accumulate_pair_constraints_normal(&self.interface_constraints, x, out); + } + + /// `A_data^T W^2 b` (only `value` and `norm` constraints ever have a + /// nonzero target; gradient/tangent/interface constraints always target + /// zero, so including them here is harmless and kept for symmetry with + /// `accumulate_data_normal`/`diagonal`). + fn data_rhs(&self, tracker: &mut MemTracker) -> Result, CoreError> { + let mut out: Vec = tracker.try_zeroed(self.dof())?; + self.write_data_rhs::(&mut out); + Ok(out) + } + + /// Write the data right-hand side of `N` into `out`. + /// The function sets `out` to zero first. It makes no new vector. + /// `out` must have `dof()` entries. + pub(crate) fn write_data_rhs(&self, out: &mut [S]) { + out.iter_mut().for_each(|v| *v = S::from_f64(0.0)); + self.add_data_rhs(out); + } + + /// Add the data right-hand side of `N` to `out`. + fn add_data_rhs(&self, out: &mut [S]) { + point_constraints_rhs(&self.value_constraints, out); + point_constraints_rhs(&self.gradient_constraints, out); + point_constraints_rhs(&self.norm_constraints, out); + point_constraints_rhs(&self.tangent_constraints, out); + } + + /// Reserve room for `n` more value rows (exact size, no doubling). + pub(crate) fn reserve_value(&mut self, n: usize) { + self.value_constraints.reserve_exact(n); + } + + /// Reserve room for `n` more gradient points (2 rows each). + pub(crate) fn reserve_gradient(&mut self, n: usize) { + self.gradient_constraints.reserve_exact(2 * n); + } + + /// Reserve room for `n` more norm points (3 rows each). + pub(crate) fn reserve_norm(&mut self, n: usize) { + self.norm_constraints.reserve_exact(3 * n); + } + + /// Reserve room for `n` more tangent rows. + pub(crate) fn reserve_tangent(&mut self, n: usize) { + self.tangent_constraints.reserve_exact(n); + } + + /// Reserve room for `n` more interface rows. + pub(crate) fn reserve_interface(&mut self, n: usize) { + self.interface_constraints.reserve_exact(n); + } + + /// Reserve room for `n` more value-bound rows. + pub(crate) fn reserve_value_inequality(&mut self, n: usize) { + self.value_inequality_constraints.reserve_exact(n); + } + + /// Reserve room for `n` more pair-bound rows. + pub(crate) fn reserve_pair_inequality(&mut self, n: usize) { + self.pair_inequality_constraints.reserve_exact(n); + } + + /// Required length of the `scratch` buffer passed to + /// [`apply_normal_operator_into`]: one `A x` slot per regularisation + /// family, per *boundary-shell row* (see `shell_row_indices`; only these + /// need the exact two-pass computation now that the deep interior is + /// handled by the fused single-kernel pass), plus one dof-length buffer + /// shared (reused sequentially) across the border stencils. The class + /// kernels need no scratch, so the length is 0 when they apply. + pub fn scratch_len(&self) -> usize { + if self.class_kernels().is_some() { + return 0; + } + self.shell().rows.len() * N_FAMILIES + self.dof() + } + + /// As [`scratch_len`], but shorter: the shell part and the border part + /// share one region. `apply_normal_operator_into` uses the shell part + /// first and the border part after it, so the two can overlap. The + /// length is at least `dof`, so that the final residual check can keep + /// `rhs` in the buffer. Use it only with the `f32` session and that check. + /// The class kernels need no scratch, so the length is 0 when they apply. + pub(crate) fn scratch_len_compact(&self) -> usize { + if self.class_kernels().is_some() { + return 0; + } + (self.shell().rows.len() * N_FAMILIES).max(self.dof()) + } + + /// As [`scratch_len`], but for [`apply_normal_operator_into_unfused`] + /// (the reference, pre-fusion computation): one `A x` slot per family + /// per grid node (not just shell rows), plus the same border-stencil + /// buffer. + pub fn scratch_len_unfused(&self) -> usize { + self.dof() * N_FAMILIES + self.dof() + } + + /// The six families' `A x`, fused into one parallel pass over grid + /// nodes (each node computes all six stencil rows in the same closure + /// call) instead of six separate `Stencil::forward_into` dispatches. + /// `scratch` is laid out as `dof` contiguous chunks of `N_FAMILIES`, + /// i.e. `scratch[node * N_FAMILIES + family]`. + fn forward_all_families_into(&self, x: &[S], scratch: &mut [S]) { + let grid = &self.grid; + let nx = grid.nsteps[0]; + let ny = grid.nsteps[1]; + scratch + .par_chunks_mut(N_FAMILIES) + .enumerate() + .with_min_len(PAR_MIN_LEN) + .for_each(|(flat, chunk)| { + let i = flat % nx; + let j = (flat / nx) % ny; + let k = flat / (nx * ny); + for (slot, (stencil, _weight)) in chunk.iter_mut().zip(self.stencils.iter()) { + // The sum runs in f64. Only the stored value is S. + *slot = if stencil.is_row_active(grid, i, j, k) { + S::from_f64( + stencil + .offsets + .iter() + .zip(stencil.coeffs.iter()) + .map(|(d, c)| { + c * x[grid + .neighbour_index(i, j, k, *d) + .expect("row within margin")] + .to_f64() + }) + .sum::(), + ) + } else { + S::default() + }; + } + }); + } + + /// `out += sum_family w_family^2 * S_family^T (A x)`, reading the fused + /// forward pass's output and fusing all six families' adjoint + /// (correlation-with-flipped-stencil) contributions into one parallel + /// pass over grid nodes as well. + fn adjoint_all_families_accumulate_into(&self, scratch: &[S], out: &mut [S]) { + let grid = &self.grid; + let nx = grid.nsteps[0]; + let ny = grid.nsteps[1]; + out.par_iter_mut() + .enumerate() + .with_min_len(PAR_MIN_LEN) + .for_each(|(flat, o)| { + let i = flat % nx; + let j = (flat / nx) % ny; + let k = flat / (nx * ny); + let mut acc = 0.0; + for (family, (stencil, weight)) in self.stencils.iter().enumerate() { + if *weight == 0.0 { + continue; + } + let value: f64 = stencil + .offsets + .iter() + .zip(stencil.coeffs.iter()) + .map(|(d, c)| { + let flipped = (-d.0, -d.1, -d.2); + match grid.neighbour_index(i, j, k, flipped) { + // The source row's (si, sj, sk) is just (i, j, k) + // shifted by `flipped`, which `neighbour_index` + // already validated as in-bounds -- so recover it + // with the same cheap add used to get `src`, + // instead of decomposing `src` back via + // `node_ijk`'s two divisions + two modulos. This + // was the single largest cost in this pass. + Some(src) => { + let si = (i as i64 + flipped.0 as i64) as usize; + let sj = (j as i64 + flipped.1 as i64) as usize; + let sk = (k as i64 + flipped.2 as i64) as usize; + if stencil.is_row_active(grid, si, sj, sk) { + c * scratch[src * N_FAMILIES + family].to_f64() + } else { + 0.0 + } + } + None => 0.0, + } + }) + .sum(); + acc += weight * weight * value; + } + *o += S::from_f64(acc); + }); + } + + /// The six curvature families' contribution to the normal operator at + /// every node at distance >= 2 from the true domain edge, computed in a + /// *single* pass reading `x` once via the combined kernel + /// (`self.combined_kernel`) instead of the two-pass "forward into + /// scratch, then adjoint reading scratch" computation + /// (`forward_all_families_into` + `adjoint_all_families_accumulate_into`, + /// still used by [`apply_normal_operator_into_unfused`]). + /// + /// Correctness: composing two radius-1 stencils (one family's `S^T S`) + /// is `(S^T S x)[c] = sum_d coeff[d] * (S x)[c-d] = sum_{d1,d2} + /// coeff[d1]*coeff[d2]*x[c-d1+d2]` -- a translation-invariant + /// radius-2 stencil in `e = d2-d1` -- *only* when every row the adjoint + /// step reads from (`c-d1` for every tap `d1`) is itself a fully active + /// row (full stencil footprint fits, i.e. not within margin 1 of the + /// true edge). Since `dist(c) >= 2` guarantees `dist(c-d1) >= 1` for + /// every `|d1| <= 1`, using the deep-interior (dist >= 2) threshold is + /// exactly the condition under which every family's combination is + /// valid at every node the fused kernel touches -- matching the + /// reference Python `_build_fused_cg_regularisation_operator`'s + /// `_dist_to_true_edge(...) >= 2` cutoff. Nodes at `dist < 2` are left + /// untouched here (added to as 0) and are fixed up exactly by + /// [`shell_regularisation_correction_into`]. + /// + /// Since `dist(c) >= 2` also guarantees every `c + e` for `|e| <= 2` + /// stays in bounds on every axis, the kernel taps can be applied via + /// direct (unchecked) flat-index arithmetic instead of + /// `grid.neighbour_index`'s bounds-checked lookup. + fn fused_interior_regularisation_into(&self, x: &[S], out: &mut [S]) { + let kernel = self.kernel(); + if kernel.is_empty() { + return; + } + let grid = &self.grid; + let nx = grid.nsteps[0]; + let ny = grid.nsteps[1]; + let nz = grid.nsteps[2]; + out.par_iter_mut() + .enumerate() + .with_min_len(PAR_MIN_LEN) + .for_each(|(flat, o)| { + let i = flat % nx; + let j = (flat / nx) % ny; + let k = flat / (nx * ny); + let dx = i.min(nx - 1 - i); + let dy = j.min(ny - 1 - j); + let dz = k.min(nz - 1 - k); + if dx.min(dy).min(dz) >= 2 { + let flat_i = flat as i64; + let mut acc = 0.0; + for (flat_offset, coeff) in kernel.iter() { + acc += coeff * x[(flat_i + flat_offset) as usize].to_f64(); + } + *o += S::from_f64(acc); + } + }); + } + + /// Exact (unfused) computation of the six curvature families' + /// contribution to the normal operator, restricted to the boundary + /// shell `fused_interior_regularisation_into` skips: forward-evaluates + /// only the rows in `shell_row_indices` (dist to true edge <= 2, exactly + /// the row centres that can ever influence a dist < 2 output -- see + /// `build_shell_indices`), then adjoint-accumulates only into + /// `shell_output_indices` (dist < 2). Both passes cost O(boundary + /// surface area), not O(dof), for any grid where the interior is large + /// relative to its boundary. + /// + /// Mirrors `forward_all_families_into`/`adjoint_all_families_accumulate_into`'s + /// arithmetic exactly (same row-activity checks, same summation order + /// per family), just restricted to the shell row/output index sets and + /// using `shell_row_lookup` instead of a dense `flat * N_FAMILIES` + /// offset to find a row's scratch slot -- so this produces bit-for-bit + /// identical values to the unfused computation at every shell node. + fn shell_regularisation_correction_into( + &self, + x: &[S], + shell_scratch: &mut [S], + out: &mut [S], + ) { + let shell = self.shell(); + if shell.rows.is_empty() { + return; + } + let grid = &self.grid; + + shell_scratch + .par_chunks_mut(N_FAMILIES) + .zip(shell.rows.par_iter()) + .with_min_len(PAR_MIN_LEN) + .for_each(|(chunk, &flat)| { + let (i, j, k) = grid.node_ijk(flat as usize); + for (slot, (stencil, weight)) in chunk.iter_mut().zip(self.stencils.iter()) { + *slot = if *weight != 0.0 && stencil.is_row_active(grid, i, j, k) { + S::from_f64( + stencil + .offsets + .iter() + .zip(stencil.coeffs.iter()) + .map(|(d, c)| { + c * x[grid + .neighbour_index(i, j, k, *d) + .expect("row within margin")] + .to_f64() + }) + .sum::(), + ) + } else { + S::default() + }; + } + }); + + let contributions: Vec = shell + .outputs + .par_iter() + .with_min_len(PAR_MIN_LEN) + .map(|&flat| { + let (i, j, k) = grid.node_ijk(flat as usize); + let mut acc = 0.0; + for (family, (stencil, weight)) in self.stencils.iter().enumerate() { + if *weight == 0.0 { + continue; + } + let value: f64 = stencil + .offsets + .iter() + .zip(stencil.coeffs.iter()) + .map(|(d, c)| { + let flipped = (-d.0, -d.1, -d.2); + match grid.neighbour_index(i, j, k, flipped) { + Some(src) => { + let si = (i as i64 + flipped.0 as i64) as usize; + let sj = (j as i64 + flipped.1 as i64) as usize; + let sk = (k as i64 + flipped.2 as i64) as usize; + if stencil.is_row_active(grid, si, sj, sk) { + let pos = shell.lookup[src]; + debug_assert!( + pos != u32::MAX, + "shell adjoint referenced a row outside the precomputed shell row set" + ); + c * shell_scratch[pos as usize * N_FAMILIES + family] + .to_f64() + } else { + 0.0 + } + } + None => 0.0, + } + }) + .sum(); + acc += weight * weight * value; + } + acc + }) + .collect(); + + for (&flat, value) in shell.outputs.iter().zip(contributions.iter()) { + out[flat as usize] += S::from_f64(*value); + } + } + + /// The full weighted normal-equations operator, writing into `out` and + /// using `scratch` (length [`scratch_len`]) as the boundary-shell family + /// scratch plus the border stencils' (sequentially-reused) scratch: + /// `N x = (A_data^T W^2 A_data + sum_family w_family^2 S_family^T S_family + /// + sum_border w_border^2 B^T B + ridge^2 I) x`. + /// + /// This is the allocation-free form used inside the CG hot loop (see + /// `solve`); `scratch`/`out` are reused across every iteration instead + /// of being (re)allocated. The interior curvature regularisation term is + /// now computed as one fused single-kernel pass over the deep interior + /// (`fused_interior_regularisation_into`, reading `x` once) plus a small + /// exact two-pass correction restricted to the boundary shell + /// (`shell_regularisation_correction_into`) -- collapsing what used to + /// be two full-`dof` passes (forward, then adjoint) into roughly one + /// pass's worth of memory traffic; see [`apply_normal_operator_into_unfused`] + /// for the reference (pre-fusion) computation this is checked against. + /// `out`'s initial zeroing and the ridge term are folded into a single + /// parallel pass (`out[i] = ridge^2 * x[i]`, or zero if there's no ridge + /// damping) rather than a serial zero pass followed by a separate + /// serial ridge pass at the end. + /// + /// When every axis has at least 7 nodes and there is no directional + /// regularisation, the regularisation and the ridge term are one sweep + /// with the per-class kernels (see `class_stencil.rs`), and `scratch` is + /// not used. The data rows are added after the sweep. + pub fn apply_normal_operator_into(&self, x: &[S], scratch: &mut [S], out: &mut [S]) { + if let Some(kernels) = self.class_kernels() { + kernels.apply_into(x, self.ridge_factor * self.ridge_factor, out); + self.accumulate_data_normal(x, out); + return; + } + if self.ridge_factor != 0.0 { + let r2 = self.ridge_factor * self.ridge_factor; + out.par_iter_mut() + .zip(x.par_iter()) + .with_min_len(PAR_MIN_LEN) + .for_each(|(o, xi)| *o = S::from_f64(r2 * xi.to_f64())); + } else { + out.par_iter_mut() + .with_min_len(PAR_MIN_LEN) + .for_each(|o| *o = S::from_f64(0.0)); + } + self.accumulate_data_normal(x, out); + // The border part follows the shell part. If the buffer is shorter + // (see `scratch_len_compact`), the border part starts at 0. This is + // safe: the shell step is finished before the border steps start, + // and the border steps read only the entries that they wrote. + let shell_len = self.shell().rows.len() * N_FAMILIES; + let dof = self.dof(); + let border_start = if scratch.len() >= shell_len + dof { + shell_len + } else { + 0 + }; + self.fused_interior_regularisation_into(x, out); + self.shell_regularisation_correction_into(x, &mut scratch[..shell_len], out); + let border_scratch = &mut scratch[border_start..border_start + dof]; + self.accumulate_border_normal_into(x, border_scratch, out); + self.accumulate_directional_normal_into(x, border_scratch, out); + } + + /// The six curvature families' contribution to the normal operator from + /// per-node directional-regularisation weights (see + /// `add_directional_regularisation`), a no-op if none have been added. + /// Reuses `scratch` (a `dof`-length buffer, e.g. the same one + /// `border_stencils` sequentially reuses) as each family's intermediate + /// `A x` buffer -- safe because `Stencil::forward_into` (unlike + /// `EdgeStencil::forward_into`) always overwrites every entry of its + /// output, so no stale data can leak between families. + fn accumulate_directional_normal_into( + &self, + x: &[S], + scratch: &mut [S], + out: &mut [S], + ) { + let Some(weight2) = &self.directional_weight2 else { + return; + }; + for (family, (stencil, _)) in self.stencils.iter().enumerate() { + // Same steps as `Stencil::accumulate_normal_weighted_into`, + // but for any vector element type S. Sums run in f64. + stencil.forward_into(&self.grid, x, scratch); + scratch + .par_iter_mut() + .zip(weight2[family].par_iter()) + .with_min_len(PAR_MIN_LEN) + .for_each(|(s, w2)| *s = S::from_f64(s.to_f64() * w2)); + stencil.adjoint_accumulate_into(&self.grid, scratch, 1.0, out); + } + } + + /// `out += sum_border w_border^2 B^T B x`. `scratch` holds each + /// border stencil's `B x` in turn. Same steps as + /// `EdgeStencil::accumulate_normal_into`, for any vector element type S. + fn accumulate_border_normal_into(&self, x: &[S], scratch: &mut [S], out: &mut [S]) { + for (stencil, weight) in &self.border_stencils { + if *weight == 0.0 { + continue; + } + stencil.forward_into(&self.grid, x, scratch); + stencil.adjoint_accumulate_into(&self.grid, scratch, weight * weight, out); + } + } + + /// Reference (pre-fusion) computation of the normal operator's interior + /// curvature contribution, using the original "forward all six families + /// into scratch, then adjoint pass reading scratch" two-pass approach + /// (`forward_all_families_into` + `adjoint_all_families_accumulate_into`) + /// unconditionally at every node, instead of + /// `apply_normal_operator_into`'s fused-interior + exact-shell split. + /// `scratch` must be at least [`scratch_len_unfused`] long. Kept only + /// for cross-checking the fused fast path is bit-for-bit-close + /// equivalent (see the `fused_matches_unfused_*` tests) and for + /// before/after profiling (see `profile_apply_1e6`/`profile_apply_1e6_unfused`); + /// not used by `solve()`. + pub fn apply_normal_operator_into_unfused( + &self, + x: &[S], + scratch: &mut [S], + out: &mut [S], + ) { + if self.ridge_factor != 0.0 { + let r2 = self.ridge_factor * self.ridge_factor; + out.par_iter_mut() + .zip(x.par_iter()) + .with_min_len(PAR_MIN_LEN) + .for_each(|(o, xi)| *o = S::from_f64(r2 * xi.to_f64())); + } else { + out.par_iter_mut() + .with_min_len(PAR_MIN_LEN) + .for_each(|o| *o = S::from_f64(0.0)); + } + self.accumulate_data_normal(x, out); + let (family_scratch, border_scratch) = scratch.split_at_mut(self.dof() * N_FAMILIES); + self.forward_all_families_into(x, family_scratch); + self.adjoint_all_families_accumulate_into(family_scratch, out); + self.accumulate_border_normal_into(x, border_scratch, out); + self.accumulate_directional_normal_into(x, border_scratch, out); + } + + /// `N x`, allocating its own output and scratch buffers. Convenience + /// wrapper around [`apply_normal_operator_into`] for callers outside the + /// CG hot loop (e.g. tests). + pub fn apply_normal_operator(&self, x: &[f64]) -> Vec { + let mut out = vec![0.0; self.dof()]; + let mut scratch = vec![0.0; self.scratch_len()]; + self.apply_normal_operator_into(x, &mut scratch, &mut out); + out + } + + /// `N x` via the reference (pre-fusion) computation -- see + /// [`apply_normal_operator_into_unfused`]. Allocates its own output and + /// scratch buffers; only used by tests/benchmarks. + pub fn apply_normal_operator_unfused(&self, x: &[f64]) -> Vec { + let mut out = vec![0.0; self.dof()]; + let mut scratch = vec![0.0; self.scratch_len_unfused()]; + self.apply_normal_operator_into_unfused(x, &mut scratch, &mut out); + out + } + + /// Relative residual `|rhs - N x| / |rhs|`, computed in f64. + /// This is the same value that the CG reports: `rhs` is the data + /// right-hand side, `N` has the regularisation and data terms, and + /// the inequality term is not included. The norm in the divisor has + /// the floor `1e-300`, as in `cg.rs`. The function allocates one + /// output vector and one scratch buffer (the same size as for one + /// operator apply). The scratch buffer also holds `rhs` after the + /// apply. The function builds no matrix. + pub(crate) fn residual_f64_of(&self, x: &[f64]) -> f64 { + let n = self.dof(); + let mut out = vec![0.0_f64; n]; + // At least `n` entries, so that the buffer can hold rhs after the apply. + let mut scratch = vec![0.0_f64; self.scratch_len().max(n)]; + self.apply_normal_operator_into(x, &mut scratch, &mut out); + // The scratch buffer has at least `n` entries. Reuse it for rhs. + let rhs = &mut scratch[..n]; + self.write_data_rhs::(rhs); + Self::relative_residual(rhs, &out) + } + + /// As [`residual_f64_of`](Self::residual_f64_of), with a low peak + /// memory: the scratch buffer has no border part if the problem needs + /// none. With the class kernels there is no scratch and no `rhs` + /// vector: `out` becomes `rhs - N x` in place, and then holds `rhs` for + /// the divisor. The value can differ from `residual_f64_of` in the + /// last bits. + pub(crate) fn residual_f64_compact(&self, x: &[f64]) -> f64 { + let n = self.dof(); + let mut out = vec![0.0_f64; n]; + if self.class_kernels().is_some() { + self.apply_normal_operator_into(x, &mut [], &mut out); + out.iter_mut().for_each(|v| *v = -*v); + self.add_data_rhs(&mut out); + let r2: f64 = out.iter().map(|v| v * v).sum(); + self.write_data_rhs::(&mut out); + let b2: f64 = out.iter().map(|v| v * v).sum(); + return r2.sqrt() / b2.sqrt().max(1e-300); + } + let mut scratch = vec![0.0_f64; self.scratch_len_compact().max(n)]; + self.apply_normal_operator_into(x, &mut scratch, &mut out); + // The scratch buffer has at least `n` entries. Reuse it for rhs. + let rhs = &mut scratch[..n]; + self.write_data_rhs::(rhs); + Self::relative_residual(rhs, &out) + } + + /// `|rhs - nx| / |rhs|`, with the floor `1e-300` in the divisor. + fn relative_residual(rhs: &[f64], nx: &[f64]) -> f64 { + let mut r2 = 0.0_f64; + let mut b2 = 0.0_f64; + for (b, nx) in rhs.iter().zip(nx.iter()) { + let d = b - nx; + r2 += d * d; + b2 += b * b; + } + r2.sqrt() / b2.sqrt().max(1e-300) + } + + /// Public name for [`residual_f64_of`](Self::residual_f64_of). + pub fn residual_f64(&self, x: &[f64]) -> f64 { + self.residual_f64_of(x) + } + + pub fn rhs(&self) -> Vec { + self.try_rhs(&mut MemTracker::new(None)) + .expect("rhs must fit in memory") + } + + /// As [`rhs`](Self::rhs). The vector is counted by `tracker`. + pub(crate) fn try_rhs(&self, tracker: &mut MemTracker) -> Result, CoreError> { + self.data_rhs(tracker) + } + + /// `diag(N)`, the normal operator's diagonal, computed directly (no `N` + /// ever assembled) for use as a Jacobi preconditioner. + pub fn diagonal(&self) -> Vec { + self.try_diagonal(&mut MemTracker::new(None)) + .expect("diagonal must fit in memory") + } + + /// As [`diagonal`](Self::diagonal). The vector is counted by `tracker`. + pub(crate) fn try_diagonal(&self, tracker: &mut MemTracker) -> Result, CoreError> { + let mut diag: Vec = tracker.try_zeroed(self.dof())?; + point_constraints_diagonal(&self.value_constraints, &mut diag); + point_constraints_diagonal(&self.gradient_constraints, &mut diag); + point_constraints_diagonal(&self.norm_constraints, &mut diag); + point_constraints_diagonal(&self.tangent_constraints, &mut diag); + pair_constraints_diagonal(&self.interface_constraints, &mut diag); + for (stencil, weight) in &self.stencils { + if *weight == 0.0 { + continue; + } + let w2 = weight * weight; + let d = stencil.diagonal(&self.grid); + for (o, di) in diag.iter_mut().zip(d.iter()) { + *o += w2 * di; + } + } + for (stencil, weight) in &self.border_stencils { + if *weight == 0.0 { + continue; + } + let w2 = weight * weight; + let d = stencil.diagonal(&self.grid); + for (o, di) in diag.iter_mut().zip(d.iter()) { + *o += w2 * di; + } + } + if let Some(weight2) = &self.directional_weight2 { + for (family, (stencil, _)) in self.stencils.iter().enumerate() { + let d = stencil.diagonal_weighted(&self.grid, &weight2[family]); + for (o, di) in diag.iter_mut().zip(d.iter()) { + *o += di; + } + } + } + if self.ridge_factor != 0.0 { + let r2 = self.ridge_factor * self.ridge_factor; + for o in diag.iter_mut() { + *o += r2; + } + } + Ok(diag) + } + + /// The exact contribution of the `dxx` family plus any x-axis border + /// (`dx_lower`/`dx_upper`) regularisation to the normal operator's + /// x-adjacent off-diagonal entries `N[(i,j,k), (i+1,j,k)]`, one entry + /// per dof (entry `flat`, for node `(i,j,k)`, is the coupling between + /// that node and its `i+1` neighbour; the last node on each line has no + /// `i+1` neighbour and is left at 0). Used to build + /// `PreconditionerKind::BlockJacobiX`'s per-x-line tridiagonal system + /// (`diagonal()` supplies the diagonal, this supplies the off-diagonal). + /// + /// SPD argument: this is the *principal submatrix* (restriction to one + /// grid line's dof, i.e. fixed `j, k`, varying `i`) of `dxx`'s and + /// border-`dx`'s own (each individually PSD, being an `S^T S` operator) + /// contributions to `N` -- computed with the exact same row-activity + /// masking `apply_normal_operator` uses (via `is_row_active`), not a + /// deep-interior approximation, so it is the honest restriction, not an + /// approximation of it. A principal submatrix of a PSD matrix is itself + /// PSD, and the direct sum of the resulting per-line blocks (i.e. the + /// block-diagonal matrix built from `diagonal()` + this off-diagonal) is + /// therefore PSD; adding the *rest* of `diagonal()` (every other + /// family's own diagonal, all individually PSD/nonneg) keeps it PSD, and + /// adding `ridge_factor^2 * I` (strictly positive whenever + /// `ridge_factor != 0`, the crate's default) makes the sum strictly + /// positive definite. So `PreconditionerKind::BlockJacobiX` is + /// guaranteed SPD under the same condition the plain Jacobi + /// preconditioner already implicitly relies on (a strictly positive + /// diagonal) -- see `solve_tridiagonal_line`'s fallback for the + /// degenerate case. + fn dxx_and_border_dx_offdiagonal(&self) -> Vec { + let grid = &self.grid; + let (nx, ny) = (grid.nsteps[0], grid.nsteps[1]); + let dof = self.dof(); + let mut off = vec![0.0; dof]; + + let w_dxx = self.regularisation.dxx; + if w_dxx != 0.0 && nx >= 2 { + let dxx_stencil = &self.stencils[0].0; + let c_m1 = coeff_at(dxx_stencil, (-1, 0, 0)); + let c0 = coeff_at(dxx_stencil, (0, 0, 0)); + let c_p1 = coeff_at(dxx_stencil, (1, 0, 0)); + let w2 = w_dxx * w_dxx; + off.par_chunks_mut(nx) + .enumerate() + .with_min_len(PAR_MIN_LEN) + .for_each(|(line, chunk)| { + let j = line % ny; + let k = line / ny; + #[allow(clippy::needless_range_loop)] + for i in 0..nx - 1 { + let active_here = dxx_stencil.is_row_active(grid, i, j, k); + let active_next = dxx_stencil.is_row_active(grid, i + 1, j, k); + let mut v = 0.0; + if active_next { + v += c_m1 * c0; + } + if active_here { + v += c0 * c_p1; + } + chunk[i] += w2 * v; + } + }); + } + + for (stencil, weight) in &self.border_stencils { + if stencil.axis != 0 || *weight == 0.0 || nx < 2 { + continue; + } + let w2 = weight * weight; + let c0 = stencil + .taps + .iter() + .find(|(o, _)| *o == 0) + .map(|(_, c)| *c) + .unwrap_or(0.0); + if let Some((_, c_m1)) = stencil.taps.iter().find(|(o, _)| *o == -1) { + let pair_i = stencil.layer - 1; + off.par_chunks_mut(nx) + .for_each(|chunk| chunk[pair_i] += w2 * c_m1 * c0); + } + if let Some((_, c_p1)) = stencil.taps.iter().find(|(o, _)| *o == 1) { + let pair_i = stencil.layer; + off.par_chunks_mut(nx) + .for_each(|chunk| chunk[pair_i] += w2 * c0 * c_p1); + } + } + + off + } + + /// Solve for the node values with Jacobi-preconditioned, matrix-free + /// conjugate gradient. Convenience wrapper around + /// [`solve_with`](Self::solve_with) fixed to + /// [`PreconditionerKind::Jacobi`], kept so existing callers (Rust and + /// Python) don't need to know about `PreconditionerKind` at all. + pub fn solve(&self, max_iter: usize, tol: f64) -> CgResult { + self.solve_with(max_iter, tol, PreconditionerKind::Jacobi) + } + + /// Solve for the node values with matrix-free conjugate gradient, using + /// `kind` to choose the preconditioner (see [`PreconditionerKind`]). No + /// sparse matrix is ever built, and -- since `apply`/`precondition` + /// write into caller-supplied buffers -- the only allocations for the + /// whole solve are `rhs`, `diag`, the shared `apply` scratch buffer, + /// `BlockJacobiX`'s off-diagonal + Thomas-algorithm scratch (only built + /// if that variant is requested), and conjugate gradient's own five + /// `dof`-length vectors; nothing is (re)allocated per iteration. + pub fn solve_with(&self, max_iter: usize, tol: f64, kind: PreconditionerKind) -> CgResult { + self.solve_with_tracked(max_iter, tol, kind, &mut MemTracker::new(None)) + .expect("solve must fit in memory") + } + + /// As [`solve_with`](Self::solve_with). It builds the tables first (like + /// `prepare`) and counts the rhs, diagonal, scratch and line buffers with + /// `tracker`. It returns an error, not an abort, if a buffer is too big. + pub(crate) fn solve_with_tracked( + &self, + max_iter: usize, + tol: f64, + kind: PreconditionerKind, + tracker: &mut MemTracker, + ) -> Result { + // This function takes `&self`, so it uses the `OnceLock` path of `prepare`. + self.ensure_tables(tracker)?; + let rhs = self.try_rhs(tracker)?; + let diag = self.try_diagonal(tracker)?; + let mut scratch: Vec = tracker.try_zeroed(self.scratch_len())?; + + let apply = |x: &[f64], out: &mut [f64]| { + self.apply_normal_operator_into(x, &mut scratch, out); + }; + + match kind { + PreconditionerKind::Jacobi => { + let precondition = |r: &[f64], out: &mut [f64]| { + out.par_iter_mut() + .zip(r.par_iter()) + .zip(diag.par_iter()) + .with_min_len(PAR_MIN_LEN) + .for_each(|((o, ri), di)| { + *o = if *di > 0.0 { ri / di } else { *ri }; + }); + }; + Ok(conjugate_gradient_preconditioned( + apply, + precondition, + &rhs, + None, + max_iter, + tol, + )) + } + PreconditionerKind::BlockJacobiX => { + let nx = self.grid.nsteps[0]; + let offdiag = self.dxx_and_border_dx_offdiagonal(); + let mut cprime: Vec = tracker.try_zeroed(self.dof())?; + let precondition = |r: &[f64], out: &mut [f64]| { + out.par_chunks_mut(nx) + .zip(cprime.par_chunks_mut(nx)) + .enumerate() + .with_min_len(PAR_MIN_LEN) + .for_each(|(line, (out_chunk, cprime_chunk))| { + let base = line * nx; + let r_chunk = &r[base..base + nx]; + let d_chunk = &diag[base..base + nx]; + let e_chunk = &offdiag[base..base + nx]; + solve_tridiagonal_line( + d_chunk, + e_chunk, + r_chunk, + cprime_chunk, + out_chunk, + ); + }); + }; + Ok(conjugate_gradient_preconditioned( + apply, + precondition, + &rhs, + None, + max_iter, + tol, + )) + } + } + } + + /// Solve for the node values with the box (inequality) constraints + /// enforced, via classical ADMM (Boyd et al.'s ADMM survey's box-splitting + /// form) on top of the existing matrix-free equality normal-equations + /// operator/rhs (`N`/`rhs`, i.e. everything [`solve`](Self::solve) + /// already handles: value/gradient/norm/tangent/interface constraints, + /// curvature + border regularisation, ridge damping) plus the + /// inequality rows added via + /// [`add_value_inequality_constraint`](Self::add_value_inequality_constraint)/ + /// [`add_inequality_pair_constraint`](Self::add_inequality_pair_constraint), + /// stacked matrix-free as `Q` (see `apply_inequality_operator`/ + /// `accumulate_inequality_normal`/`accumulate_inequality_transpose`). + /// + /// Ported from `loop_interpolation`'s `loopsolver/admm_method.py` + + /// `admm_solver.py`, scoped to the *core* algorithm those two files + /// implement (a fixed penalty `rho`, i.e. the Python default path + /// without `admm_weight_final`/adaptive rho -- see Boyd et al., "Distributed + /// Optimization and Statistical Learning via ADMM", section 5.2's box-constrained + /// least squares example, which this is a direct instance of): + /// + /// - **x-update**: solve `(N + rho * Q^T Q) x = rhs + rho * Q^T (z - u)` + /// with [`cg::conjugate_gradient_preconditioned`] (Jacobi-preconditioned; + /// see below for why `BlockJacobiX` isn't offered here), warm-started + /// from the previous outer iteration's `x` (cheap and exact for this + /// fixed-`rho` loop, since the operator doesn't change between outer + /// iterations -- unlike the Python original's `reuse_inner_solve` + /// x0-injection machinery, which exists to serve its per-iteration + /// inner-solver-tolerance schedules and multiple solver backends, + /// neither of which exists here). + /// - **z-update**: `z = clip(Q x + u, lower, upper)` elementwise, + /// exactly `admm_method.py`'s box projection. + /// - **u-update**: `u += Q x - z`. + /// - **stopping**: standard ADMM primal/dual residual check + /// (`primal = ||Qx - z||`, `dual = rho * ||z - z_prev||`, stopping when + /// both fall under `sqrt(m) * abs_tol + rel_tol * scale` -- the same + /// shape of criterion `admm_solver.py`'s `eps_pri`/`eps_dual` use, with + /// `scale = max(||Qx||, ||z||)` for the primal tolerance and `rho * + /// ||u||` for the dual one) or `max_outer_iter` outer iterations, + /// whichever comes first. + /// + /// If there are no inequality constraints at all + /// (`n_inequality_constraints() == 0`), this degenerates to (and simply + /// delegates to) [`solve`](Self::solve) with `(inner_max_iter, inner_tol)` + /// -- an equality-only problem shouldn't pay any ADMM overhead (no `Q`, + /// no outer loop, no `z`/`u` state) when there's nothing to project. + /// + /// Deliberately descoped from the Python original (left for a future + /// pass, see `TASKS.md`): active-set row subsetting (`active_set`), + /// adaptive rho (`adaptive_rho`), per-iteration inner-solver-tolerance + /// schedules (`inner_rtol_start`/`inner_maxiter_start`/etc.), a + /// non-constant rho schedule (`admm_weight_final`), warm-start reuse + /// across ADMM iterations via a separately-injected `x0` guess for the + /// *inner* solver (this crate instead just continues CG from the + /// previous outer `x`, which is simpler and exact rather than a + /// heuristic since `rho`, and therefore the inner operator, never + /// changes), and multiple inner-solver backends (`lsmr`/`lsqr`/`cg` + /// choice -- CG is the only inner solver this crate has at all, and is + /// the right choice here: matrix-free, SPD, already used everywhere + /// else in this crate). + /// + /// SPD argument for the x-update operator `N + rho * Q^T Q`: `N` is + /// already established SPD whenever `ridge_factor != 0` (the crate + /// default -- see [`diagonal`](Self::diagonal)'s ridge term and the + /// `BlockJacobiX` SPD argument on [`dxx_and_border_dx_offdiagonal`], which + /// relies on the same fact). `Q^T Q` is a Gram matrix -- PSD for *any* + /// real matrix `Q`, regardless of its rank or row structure -- and + /// `rho > 0` keeps `rho * Q^T Q` PSD too, so `N + rho * Q^T Q` is + /// SPD + PSD = SPD unconditionally; no special argument about `Q`'s + /// structure (unlike `BlockJacobiX`'s principal-submatrix argument) is + /// needed. This is also *why* `BlockJacobiX` isn't offered as this + /// solve's preconditioner: it's only proven to capture the `dxx`-family/ + /// x-axis-border coupling specifically, and would silently ignore (not + /// mis-handle, just not exploit) the extra `rho * Q^T Q` coupling an + /// inequality-pair row spanning two arbitrary points introduces -- + /// correct as a preconditioner (any SPD preconditioner is valid for CG), + /// but not clearly still the *better* one once that coupling is real. + /// Plain Jacobi is always well-defined (a strictly positive diagonal, by + /// the same SPD argument) and keeps this new code path's preconditioner + /// choice simple to reason about. + /// + /// Parameters + /// ---------- + /// rho : the (fixed) ADMM penalty weight. + /// max_outer_iter : outer ADMM iteration cap. + /// inner_max_iter, inner_tol : passed through to every outer iteration's + /// inner CG x-update solve. + /// admm_abs_tol, admm_rel_tol : absolute/relative primal-dual stopping + /// tolerances (see the stopping-criterion description above). + pub fn solve_admm( + &self, + rho: f64, + max_outer_iter: usize, + inner_max_iter: usize, + inner_tol: f64, + admm_abs_tol: f64, + admm_rel_tol: f64, + ) -> AdmmResult { + let m = self.n_inequality_constraints(); + if m == 0 { + let cg = self.solve(inner_max_iter, inner_tol); + return AdmmResult { + x: cg.x, + outer_iterations: 0, + converged: cg.converged, + inner_iterations_total: cg.iterations, + }; + } + + let n = self.dof(); + let (lower, upper) = self.inequality_bounds(); + let base_rhs = self.rhs(); + + // `diag(N + rho * Q^T Q)` never changes across outer iterations + // (rho is fixed), so -- unlike `rhs_admm`, which depends on the + // ever-changing `z - u` -- it's computed once, outside the loop. + let mut diag = self.diagonal(); + let q_diag = self.inequality_diagonal(rho); + for (d, qd) in diag.iter_mut().zip(q_diag.iter()) { + *d += qd; + } + + let mut z = vec![0.0; m]; + let mut u = vec![0.0; m]; + let mut zu = vec![0.0; m]; + let mut x = vec![0.0; n]; + let mut rhs_admm = vec![0.0; n]; + let mut normal_scratch = vec![0.0; self.scratch_len()]; + + let mut outer_iterations = 0usize; + let mut converged = false; + let mut inner_iterations_total = 0usize; + let sqrt_m = (m as f64).sqrt(); + + for iter in 0..max_outer_iter { + outer_iterations = iter + 1; + + for i in 0..m { + zu[i] = z[i] - u[i]; + } + rhs_admm.copy_from_slice(&base_rhs); + self.accumulate_inequality_transpose(&zu, rho, &mut rhs_admm); + + let apply = |xin: &[f64], out: &mut [f64]| { + self.apply_normal_operator_into(xin, &mut normal_scratch, out); + self.accumulate_inequality_normal(xin, rho, out); + }; + let precondition = |r: &[f64], out: &mut [f64]| { + for i in 0..n { + out[i] = if diag[i] > 0.0 { r[i] / diag[i] } else { r[i] }; + } + }; + let cg_result = conjugate_gradient_preconditioned( + apply, + precondition, + &rhs_admm, + Some(x.clone()), + inner_max_iter, + inner_tol, + ); + x = cg_result.x; + inner_iterations_total += cg_result.iterations; + + let qx = self.apply_inequality_operator(&x); + let z_prev = z.clone(); + for i in 0..m { + let v = qx[i] + u[i]; + z[i] = v.max(lower[i]).min(upper[i]); + } + for i in 0..m { + u[i] += qx[i] - z[i]; + } + + let primal_norm = (0..m).map(|i| (qx[i] - z[i]).powi(2)).sum::().sqrt(); + let dual_norm = rho + * (0..m) + .map(|i| (z[i] - z_prev[i]).powi(2)) + .sum::() + .sqrt(); + let qx_norm = qx.iter().map(|v| v * v).sum::().sqrt(); + let z_norm = z.iter().map(|v| v * v).sum::().sqrt(); + let u_norm = rho * u.iter().map(|v| v * v).sum::().sqrt(); + let eps_pri = sqrt_m * admm_abs_tol + admm_rel_tol * qx_norm.max(z_norm); + let eps_dual = sqrt_m * admm_abs_tol + admm_rel_tol * u_norm; + + if primal_norm <= eps_pri && dual_norm <= eps_dual { + converged = true; + break; + } + } + + AdmmResult { + x, + outer_iterations, + converged, + inner_iterations_total, + } + } + + /// Solve for the node values with the box (inequality) constraints + /// enforced *exactly* (to `constraint_tol`), via Projected Active-Set + /// Conjugate Gradient (AS-CG) -- see `Inequalities.md`'s "Method A". + /// This is a primal active-set method for the QP `min 1/2 x^T N x - b^T + /// x s.t. lower <= Q x <= upper` (`N`/`b` the same equality + /// normal-equations operator/rhs [`solve`](Self::solve) uses; `Q` the + /// stacked inequality rows from + /// [`add_value_inequality_constraint`](Self::add_value_inequality_constraint)/ + /// [`add_inequality_pair_constraint`](Self::add_inequality_pair_constraint)): + /// + /// - **Working set**: a subset of inequality rows currently held exactly + /// at one of their bounds (`ActiveRow` -- row index, the bound value, + /// and which bound). Every other row is free. + /// - **Feasibility**: `x` is kept exactly on the working set's + /// constraint surface (`Q_W x = bound_W`) via the minimum-distance + /// affine correction `active_set_restore_feasibility`, and any row + /// already violated at that corrected `x` (checked by direct scan, + /// not just "found mid-CG") is folded into the working set before any + /// CG work happens -- so this also functions as its own feasibility + /// ("Phase 1") step, with no separate solver needed to find a + /// starting point. + /// - **Free-subspace solve**: for the current working set, CG runs in + /// the null space of `Q_W`, using `active_set_project_weighted` + /// (a Jacobi-preconditioned generalisation of `I - Q_W^T (Q_W + /// Q_W^T)^-1 Q_W`) as its "preconditioner" -- the standard *projected + /// preconditioned* CG method (Nocedal & Wright, "Numerical + /// Optimization", Algorithm 16.2) for equality-constrained QP, applied + /// here to the current working set treated as equality constraints, + /// with `diag(N)` playing the same preconditioning role + /// [`solve`](Self::solve)'s Jacobi preconditioner does. + /// - **Step truncation**: before taking a CG step, every row *not* in + /// the working set is checked for whether the step would cross one of + /// its bounds; if so, the step is clipped to exactly reach the + /// nearest crossing, that row joins the working set, and CG restarts + /// (fresh conjugate directions, per `Inequalities.md`) on the new, + /// larger working set -- since `x` lands exactly on the new bound by + /// construction, `active_set_restore_feasibility`'s next call on it is + /// a no-op (see that function's docs). + /// - **Node unfreezing**: once projected CG converges for a working set + /// (projected residual ~0), the Lagrange multipliers implied by that + /// stationarity point are recovered from a Gram solve + /// (`Q_W Q_W^T mu = -Q_W r`, `lambda = mu * sign`); any active row + /// with `lambda < -constraint_tol` is not a genuine KKT point (forcing + /// it to its bound is *hurting* the objective), so the most negative + /// one is released and CG restarts on the smaller working set. No + /// active row with a wrong-signed multiplier and no residual outside + /// the null space together are exactly the KKT conditions for this QP, + /// so that is this method's convergence criterion. + /// + /// If there are no inequality constraints at all + /// (`n_inequality_constraints() == 0`), this degenerates to (and simply + /// delegates to) [`solve`](Self::solve) with `(inner_max_iter, + /// inner_tol)`, matching `solve_admm`'s fast path. + /// + /// Unlike [`solve_admm`](Self::solve_admm) (which only ever satisfies + /// bounds up to the ADMM primal residual tolerance -- `Inequalities.md`'s + /// "asymptotic contact"), every *active* row here is satisfied to + /// floating-point precision by construction (`active_set_restore_feasibility` + /// runs after every working-set change), matching the memo's "exact + /// boundary contact" case for AS-CG. This comes at the cost of + /// `O(|W|^3)` Cholesky factorisations of the working set's Gram matrix + /// on every working-set change and an `O(m)` scan (`m` = total + /// inequality rows) per CG step for step-truncation checks, so this + /// solver is intended for the "low to moderate constraint density" + /// regime `Inequalities.md`'s decision matrix calls out for AS-CG, not + /// for a working set that ends up a large fraction of `m` -- see + /// [`solve_hybrid`](Self::solve_hybrid) for a warm-started variant that + /// keeps AS-CG's exact contact while amortising the discovery of a + /// (near-)correct working set into a cheap matrix-free ADMM pass. + /// + /// Parameters + /// ---------- + /// max_outer_iter : cap on the number of working-set changes. + /// inner_max_iter, inner_tol : cap / relative-residual tolerance for + /// each working-set stage's projected CG solve. + /// constraint_tol : absolute tolerance used both to decide whether a row + /// is "already violated" during the feasibility scan and to decide + /// whether a recovered Lagrange multiplier is "genuinely negative" + /// during the release check. + pub fn solve_active_set_cg( + &self, + max_outer_iter: usize, + inner_max_iter: usize, + inner_tol: f64, + constraint_tol: f64, + ) -> ActiveSetCgResult { + self.active_set_cg_core( + vec![0.0; self.dof()], + Vec::new(), + max_outer_iter, + inner_max_iter, + inner_tol, + constraint_tol, + ) + } + + /// Shared active-set core: [`solve_active_set_cg`](Self::solve_active_set_cg) + /// calls this starting from `x0 = 0`/an empty working set, + /// [`solve_hybrid`](Self::solve_hybrid) calls it starting from an + /// ADMM-warmed-up `x0`/working-set guess -- see those two methods' docs + /// for the algorithm itself. `x0` need not be feasible (not even w.r.t. + /// `active0`'s own rows): the first outer iteration's + /// `active_set_restore_feasibility` call and feasibility scan bring it + /// onto (and then extend `active0` to cover) the true feasible set + /// regardless of the starting point's quality. + fn active_set_cg_core( + &self, + mut x: Vec, + mut active: Vec, + max_outer_iter: usize, + inner_max_iter: usize, + inner_tol: f64, + constraint_tol: f64, + ) -> ActiveSetCgResult { + let m = self.n_inequality_constraints(); + if m == 0 { + let cg = self.solve(inner_max_iter, inner_tol); + return ActiveSetCgResult { + x: cg.x, + outer_iterations: 0, + inner_iterations_total: cg.iterations, + converged: cg.converged, + }; + } + + let n = self.dof(); + let b = self.rhs(); + let (lower, upper) = self.inequality_bounds(); + let mut scratch = vec![0.0; self.scratch_len()]; + let b_norm = b.iter().map(|v| v * v).sum::().sqrt().max(1e-300); + // Jacobi preconditioning weight for the projected CG search + // direction only (see `active_set_project_weighted`'s docs) -- not + // used anywhere feasibility/optimality needs to be exact. + let dinv: Vec = self + .diagonal() + .iter() + .map(|d| if *d > 0.0 { 1.0 / d } else { 1.0 }) + .collect(); + + let mut is_active = vec![false; m]; + for a in &active { + is_active[a.row] = true; + } + + let mut outer_iterations = 0usize; + let mut inner_iterations_total = 0usize; + let mut converged = false; + + 'outer: while outer_iterations < max_outer_iter { + let chol: Option> = if active.is_empty() { + None + } else { + let gram = active_set_gram(self, &active); + Some(active_set_cholesky(&gram, active.len())) + }; + if let Some(c) = &chol { + active_set_restore_feasibility(self, &active, c, &mut x); + } + + // Feasibility scan: any inactive row already violated at this + // (now working-set-feasible) `x` must join the working set + // before any CG work is spent. + let mut newly_violated: Option = None; + for k in 0..m { + if is_active[k] { + continue; + } + let v = self.inequality_row_dot(k, &x); + if v < lower[k] - constraint_tol { + newly_violated = Some(ActiveRow { + row: k, + bound: lower[k], + sign: 1.0, + }); + break; + } else if v > upper[k] + constraint_tol { + newly_violated = Some(ActiveRow { + row: k, + bound: upper[k], + sign: -1.0, + }); + break; + } + } + if let Some(row) = newly_violated { + is_active[row.row] = true; + active.push(row); + outer_iterations += 1; + continue 'outer; + } + + // Jacobi-preconditioned, projected CG on the current working + // set's null space (see `active_set_project_weighted`'s docs + // for why this uses a separate weighted Gram/Cholesky from + // `chol`, which stays reserved for exact feasibility/multiplier + // work). + let chol_w: Option> = if active.is_empty() { + None + } else { + let gram_w = active_set_gram_weighted(self, &active, &dinv); + Some(active_set_cholesky(&gram_w, active.len())) + }; + let mut r = vec![0.0; n]; + let mut napply = vec![0.0; n]; + self.apply_normal_operator_into(&x, &mut scratch, &mut napply); + for i in 0..n { + r[i] = b[i] - napply[i]; + } + let mut g = active_set_project_weighted(self, &active, chol_w.as_deref(), &dinv, &r); + let mut p = g.clone(); + let mut rz_old: f64 = r.iter().zip(g.iter()).map(|(a, bb)| a * bb).sum(); + let mut g_norm = g.iter().map(|v| v * v).sum::().sqrt(); + + let mut truncated_this_stage = false; + let mut inner_iter = 0usize; + while inner_iter < inner_max_iter { + if g_norm / b_norm <= inner_tol { + break; + } + let mut np = vec![0.0; n]; + self.apply_normal_operator_into(&p, &mut scratch, &mut np); + let p_np: f64 = p.iter().zip(np.iter()).map(|(a, bb)| a * bb).sum(); + if p_np.abs() < 1e-300 { + break; + } + let mut alpha = rz_old / p_np; + + // Step truncation: does x -> x + alpha*p cross an inactive + // row's bound before reaching the unconstrained CG step? + let mut blocking: Option = None; + for k in 0..m { + if is_active[k] { + continue; + } + let qk_x = self.inequality_row_dot(k, &x); + let qk_p = self.inequality_row_dot(k, &p); + if qk_p > 1e-300 && upper[k].is_finite() { + let a_max = (upper[k] - qk_x) / qk_p; + if a_max < alpha { + alpha = a_max.max(0.0); + blocking = Some(ActiveRow { + row: k, + bound: upper[k], + sign: -1.0, + }); + } + } else if qk_p < -1e-300 && lower[k].is_finite() { + let a_max = (lower[k] - qk_x) / qk_p; + if a_max < alpha { + alpha = a_max.max(0.0); + blocking = Some(ActiveRow { + row: k, + bound: lower[k], + sign: 1.0, + }); + } + } + } + + for i in 0..n { + x[i] += alpha * p[i]; + } + inner_iter += 1; + + if let Some(row) = blocking { + is_active[row.row] = true; + active.push(row); + truncated_this_stage = true; + break; + } + + for i in 0..n { + r[i] -= alpha * np[i]; + } + let g_new = + active_set_project_weighted(self, &active, chol_w.as_deref(), &dinv, &r); + g_norm = g_new.iter().map(|v| v * v).sum::().sqrt(); + if g_norm / b_norm <= inner_tol { + g = g_new; + break; + } + let rz_new: f64 = r.iter().zip(g_new.iter()).map(|(a, bb)| a * bb).sum(); + let beta = rz_new / rz_old; + for i in 0..n { + p[i] = g_new[i] + beta * p[i]; + } + g = g_new; + rz_old = rz_new; + } + + inner_iterations_total += inner_iter; + + if truncated_this_stage { + outer_iterations += 1; + continue 'outer; + } + + // Hit `inner_max_iter` without the projected residual actually + // reaching `inner_tol`: stop here rather than trusting a + // Lagrange-multiplier check derived from a non-stationary `r` -- + // an unconverged residual can easily produce a spuriously + // wrong-signed multiplier, releasing a row that's genuinely + // active and then immediately re-adding it next iteration (via + // the feasibility scan), burning another `inner_max_iter` + // cold-restarted CG budget for no progress instead of reporting + // "not converged" and letting the caller raise the budget. + if g_norm / b_norm > inner_tol { + converged = false; + break; + } + + if active.is_empty() { + converged = true; + break; + } + + // Lagrange-multiplier release check: `mu` solves `Q_W Q_W^T mu = + // -Q_W r`; `lambda_i = mu_i * sign_i` must be >= 0 for every + // active row at a genuine KKT point (see this method's doc + // comment for the derivation). + let chol = chol.expect("non-empty active set always has a Cholesky factor"); + let w_qr = active_set_apply(self, &active, &r); + let neg_w_qr: Vec = w_qr.iter().map(|v| -v).collect(); + let mu = cholesky_solve(&chol, active.len(), &neg_w_qr); + let mut release_idx: Option = None; + let mut worst = -constraint_tol; + for (idx, (a, mu_i)) in active.iter().zip(mu.iter()).enumerate() { + let lambda = mu_i * a.sign; + if lambda < worst { + worst = lambda; + release_idx = Some(idx); + } + } + match release_idx { + Some(idx) => { + is_active[active[idx].row] = false; + active.remove(idx); + outer_iterations += 1; + } + None => { + converged = true; + break; + } + } + } + + ActiveSetCgResult { + x, + outer_iterations, + inner_iterations_total, + converged, + } + } + + /// Run exactly `iters` outer iterations of the same classical ADMM loop + /// [`solve_admm`](Self::solve_admm) uses, *without* the primal/dual + /// stopping check -- a fixed-budget "warm-up" pass rather than a solve + /// to convergence, matching `Inequalities.md`'s "Stage 1 (ADMM + /// Warm-Up)". Returns `(x, z, inner_iterations_total)`; `z` (the ADMM + /// box-projection variable) is what [`solve_hybrid`](Self::solve_hybrid) + /// uses to seed its active-set guess, since `z_i` sitting exactly at + /// `lower[i]`/`upper[i]` is precisely ADMM's signal that row `i` wants + /// to be active. + fn admm_warmup( + &self, + rho: f64, + iters: usize, + inner_max_iter: usize, + inner_tol: f64, + ) -> (Vec, Vec, usize) { + let n = self.dof(); + let m = self.n_inequality_constraints(); + let (lower, upper) = self.inequality_bounds(); + let base_rhs = self.rhs(); + + let mut diag = self.diagonal(); + let q_diag = self.inequality_diagonal(rho); + for (d, qd) in diag.iter_mut().zip(q_diag.iter()) { + *d += qd; + } + + let mut z = vec![0.0; m]; + let mut u = vec![0.0; m]; + let mut zu = vec![0.0; m]; + let mut x = vec![0.0; n]; + let mut rhs_admm = vec![0.0; n]; + let mut normal_scratch = vec![0.0; self.scratch_len()]; + let mut inner_iterations_total = 0usize; + + for _ in 0..iters { + for i in 0..m { + zu[i] = z[i] - u[i]; + } + rhs_admm.copy_from_slice(&base_rhs); + self.accumulate_inequality_transpose(&zu, rho, &mut rhs_admm); + + let apply = |xin: &[f64], out: &mut [f64]| { + self.apply_normal_operator_into(xin, &mut normal_scratch, out); + self.accumulate_inequality_normal(xin, rho, out); + }; + let precondition = |r: &[f64], out: &mut [f64]| { + for i in 0..n { + out[i] = if diag[i] > 0.0 { r[i] / diag[i] } else { r[i] }; + } + }; + let cg_result = conjugate_gradient_preconditioned( + apply, + precondition, + &rhs_admm, + Some(x.clone()), + inner_max_iter, + inner_tol, + ); + x = cg_result.x; + inner_iterations_total += cg_result.iterations; + + let qx = self.apply_inequality_operator(&x); + for i in 0..m { + let v = qx[i] + u[i]; + z[i] = v.max(lower[i]).min(upper[i]); + } + for i in 0..m { + u[i] += qx[i] - z[i]; + } + } + + (x, z, inner_iterations_total) + } + + /// Two-stage hybrid solve (`Inequalities.md`'s "Recommended Strategy"): + /// a fixed-budget matrixless ADMM warm-up + /// ([`admm_warmup`](Self::admm_warmup), `admm_warmup_iter` outer + /// iterations, processing bulk boundary violations in parallel without + /// stalling on active-set step truncation) followed by an + /// [`solve_active_set_cg`](Self::solve_active_set_cg) polish -- warm + /// started from the ADMM stage's `x` and an initial working-set guess + /// (every row whose ADMM `z` landed within `constraint_tol` of a bound) + /// -- that drives every active row to floating-point contact precision. + /// + /// The polish stage's `active_set_cg_core` doesn't require the ADMM + /// hand-off to be exact: its first working-set change unconditionally + /// re-derives feasibility from whatever `x`/working-set guess it's + /// given (see `active_set_cg_core`'s docs), so an under-converged ADMM + /// warm-up degrades to more active-set work, not incorrect results. + /// + /// If there are no inequality constraints at all + /// (`n_inequality_constraints() == 0`), delegates straight to + /// [`solve`](Self::solve) with `(polish_inner_max_iter, polish_tol)` -- + /// same fast path as `solve_admm`/`solve_active_set_cg`. + /// + /// Parameters + /// ---------- + /// rho, admm_inner_max_iter, admm_inner_tol : passed through to the ADMM + /// warm-up stage (see `solve_admm`'s docs for their meaning). + /// admm_warmup_iter : fixed number of ADMM outer iterations to run as + /// warm-up (`Inequalities.md` suggests 10-20). + /// polish_max_outer_iter, polish_inner_max_iter, polish_tol : passed + /// through to the active-set polish stage (see + /// `solve_active_set_cg`'s `max_outer_iter`/`inner_max_iter`/ + /// `inner_tol`). + /// constraint_tol : shared between the ADMM-to-active-set hand-off (how + /// close `z` must be to a bound to seed that row as active) and the + /// polish stage's own feasibility/multiplier tolerances (see + /// `solve_active_set_cg`'s `constraint_tol`). + #[allow(clippy::too_many_arguments)] + pub fn solve_hybrid( + &self, + rho: f64, + admm_warmup_iter: usize, + admm_inner_max_iter: usize, + admm_inner_tol: f64, + polish_max_outer_iter: usize, + polish_inner_max_iter: usize, + polish_tol: f64, + constraint_tol: f64, + ) -> HybridResult { + let m = self.n_inequality_constraints(); + if m == 0 { + let cg = self.solve(polish_inner_max_iter, polish_tol); + return HybridResult { + x: cg.x, + admm_warmup_iterations: 0, + admm_inner_iterations_total: 0, + polish_outer_iterations: 0, + polish_inner_iterations_total: cg.iterations, + converged: cg.converged, + }; + } + + let (lower, upper) = self.inequality_bounds(); + let (x0, z, admm_inner_iterations_total) = + self.admm_warmup(rho, admm_warmup_iter, admm_inner_max_iter, admm_inner_tol); + + let mut active0 = Vec::new(); + for k in 0..m { + if lower[k].is_finite() && (z[k] - lower[k]).abs() <= constraint_tol { + active0.push(ActiveRow { + row: k, + bound: lower[k], + sign: 1.0, + }); + } else if upper[k].is_finite() && (z[k] - upper[k]).abs() <= constraint_tol { + active0.push(ActiveRow { + row: k, + bound: upper[k], + sign: -1.0, + }); + } + } + + let polish = self.active_set_cg_core( + x0, + active0, + polish_max_outer_iter, + polish_inner_max_iter, + polish_tol, + constraint_tol, + ); + + HybridResult { + x: polish.x, + admm_warmup_iterations: admm_warmup_iter, + admm_inner_iterations_total, + polish_outer_iterations: polish.outer_iterations, + polish_inner_iterations_total: polish.inner_iterations_total, + converged: polish.converged, + } + } + + /// Evaluate the solved field at arbitrary points via trilinear + /// interpolation. Points outside the grid are returned as `NaN`. + pub fn evaluate(&self, c: &[f64], points: &[[f64; 3]]) -> Vec { + points + .iter() + .map(|p| match self.grid.trilinear_coeffs(*p) { + Some((idx, coefs)) => idx.iter().zip(coefs.iter()).map(|(i, w)| w * c[*i]).sum(), + None => f64::NAN, + }) + .collect() + } + + /// Evaluate the solved field's gradient at arbitrary (world-space) + /// points via the trilinear gradient shape function. Points outside the + /// grid are returned as `[NaN; 3]`. + pub fn evaluate_gradient(&self, c: &[f64], points: &[[f64; 3]]) -> Vec<[f64; 3]> { + points + .iter() + .map(|p| match self.grid.trilinear_gradient(*p) { + Some((idx, grad)) => { + let mut local = [0.0; 3]; + for axis in 0..3 { + local[axis] = idx + .iter() + .zip(grad.iter()) + .map(|(i, g)| c[*i] * g[axis]) + .sum(); + } + local + } + None => [f64::NAN; 3], + }) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_grid() -> StructuredGrid3D { + StructuredGrid3D::new([0.0, 0.0, 0.0], [9, 9, 9], [1.0, 1.0, 1.0]) + } + + #[test] + fn normal_operator_is_symmetric() { + let mut interp = FiniteDifferenceInterpolator::new(make_grid()); + interp.add_value_constraint([2.0, 2.0, 2.0], 1.0, 1.0); + interp.add_value_constraint([6.0, 3.0, 4.0], -0.5, 2.0); + + let n = interp.dof(); + let x: Vec = (0..n) + .map(|i| ((i * 31 + 3) % 101) as f64 / 101.0) + .collect(); + let y: Vec = (0..n).map(|i| ((i * 17 + 5) % 89) as f64 / 89.0).collect(); + + let nx = interp.apply_normal_operator(&x); + let ny = interp.apply_normal_operator(&y); + let lhs: f64 = nx.iter().zip(y.iter()).map(|(a, b)| a * b).sum(); + let rhs: f64 = x.iter().zip(ny.iter()).map(|(a, b)| a * b).sum(); + assert!( + (lhs - rhs).abs() < 1e-6 * lhs.abs().max(1.0), + "lhs={lhs} rhs={rhs}" + ); + } + + /// Symmetry check extended to cover every new constraint family + /// (gradient/norm/tangent/interface) plus border regularisation, all at + /// once -- guards against any one of them breaking `N`'s self-adjointness. + #[test] + fn normal_operator_is_symmetric_with_all_constraint_families() { + let mut interp = FiniteDifferenceInterpolator::new(make_grid()); + interp.add_value_constraint([2.0, 2.0, 2.0], 1.0, 1.0); + interp.add_gradient_constraint([3.0, 3.0, 3.0], [1.0, 0.0, 0.0], 2.0); + interp.add_norm_constraint([4.0, 4.0, 4.0], [0.3, 0.1, -0.2], 1.5); + interp.add_tangent_constraint([5.0, 5.0, 5.0], [1.0, 1.0, 0.0], 0.7); + interp.add_interface_constraints( + &[[1.0, 1.0, 1.0], [6.0, 6.0, 6.0], [2.0, 6.0, 1.0]], + &[1.0, 1.0, 2.0], + 3.0, + ); + + let n = interp.dof(); + let x: Vec = (0..n) + .map(|i| ((i * 31 + 3) % 101) as f64 / 101.0) + .collect(); + let y: Vec = (0..n).map(|i| ((i * 17 + 5) % 89) as f64 / 89.0).collect(); + + let nx = interp.apply_normal_operator(&x); + let ny = interp.apply_normal_operator(&y); + let lhs: f64 = nx.iter().zip(y.iter()).map(|(a, b)| a * b).sum(); + let rhs: f64 = x.iter().zip(ny.iter()).map(|(a, b)| a * b).sum(); + assert!( + (lhs - rhs).abs() < 1e-6 * lhs.abs().max(1.0), + "lhs={lhs} rhs={rhs}" + ); + } + + #[test] + fn recovers_affine_field_from_scattered_samples() { + // f(x, y, z) = 1 + 2x - y + 0.5z has zero curvature everywhere, so + // the regulariser shouldn't fight the data at all: CG should recover + // it (near-)exactly from a handful of scattered value constraints. + // The default border regularisation is on. The border rows are centred + // second derivatives, as in the matrix solver. They give a plane no + // penalty, and they are the only rows that fix the nodes on the faces. + let f = |p: [f64; 3]| 1.0 + 2.0 * p[0] - p[1] + 0.5 * p[2]; + let grid = make_grid(); + let mut interp = FiniteDifferenceInterpolator::new(grid); + + let samples = [ + [0.5, 0.5, 0.5], + [7.5, 1.0, 2.0], + [2.0, 6.0, 0.2], + [4.0, 4.0, 7.0], + [1.0, 7.5, 7.5], + [6.0, 2.0, 6.0], + ]; + for p in samples { + interp.add_value_constraint(p, f(p), 100.0); + } + + let result = interp.solve(2000, 1e-10); + assert!(result.converged, "iterations={}", result.iterations); + + let query = [[3.0, 3.0, 3.0], [8.0, 0.0, 8.0], [0.0, 8.0, 0.0]]; + let predicted = interp.evaluate(&result.x, &query); + for (p, v) in query.iter().zip(predicted.iter()) { + assert!( + (v - f(*p)).abs() < 1e-2, + "point={p:?} predicted={v} expected={}", + f(*p) + ); + } + } + + #[test] + fn constraints_outside_grid_are_dropped() { + let mut interp = FiniteDifferenceInterpolator::new(make_grid()); + interp.add_value_constraint([-5.0, 0.0, 0.0], 1.0, 1.0); + assert_eq!(interp.n_value_constraints(), 0); + + interp.add_gradient_constraint([-5.0, 0.0, 0.0], [1.0, 0.0, 0.0], 1.0); + assert_eq!(interp.n_gradient_constraints(), 0); + + interp.add_norm_constraint([-5.0, 0.0, 0.0], [1.0, 0.0, 0.0], 1.0); + assert_eq!(interp.n_norm_constraints(), 0); + + interp.add_tangent_constraint([-5.0, 0.0, 0.0], [1.0, 0.0, 0.0], 1.0); + assert_eq!(interp.n_tangent_constraints(), 0); + + interp.add_interface_constraints(&[[-5.0, 0.0, 0.0], [-6.0, 0.0, 0.0]], &[1.0, 1.0], 1.0); + assert_eq!(interp.n_interface_constraints(), 0); + } + + /// Recover a plane from gradient (orthogonal-direction) constraints plus + /// a *sparse* pair of value constraints. Gradient-orthogonal rows are + /// homogeneous (target zero) and only fix gradient *direction*: the + /// all-zero field trivially satisfies them (and zero curvature) too, so + /// they alone -- or with only a single value pin -- cannot rule out the + /// degenerate flat solution. Two value constraints at different points + /// with different targets break that degeneracy (fixing the two + /// remaining unknowns, the plane's constant term and its magnitude along + /// the known gradient direction) without otherwise being enough to pin + /// down a full 3D affine field (4 unknowns) by themselves -- so this is + /// a genuine test that the gradient constraints are doing real, + /// direction-fixing work, not just a data-fit test they're irrelevant to. + #[test] + fn recovers_plane_from_gradient_constraints_plus_sparse_value_pins() { + // The default border regularisation is on. The border rows are centred + // second derivatives, so a plane has no penalty. The border rows are + // also the only rows that fix the nodes on the faces. + let gradient = [2.0, -1.0, 0.5]; + let f = |p: [f64; 3]| 1.0 + gradient[0] * p[0] + gradient[1] * p[1] + gradient[2] * p[2]; + let grid = make_grid(); + let mut interp = FiniteDifferenceInterpolator::new(grid); + + for p in [ + [1.0, 1.0, 1.0], + [7.0, 2.0, 3.0], + [3.0, 6.0, 1.0], + [5.0, 4.0, 6.0], + ] { + interp.add_gradient_constraint(p, gradient, 200.0); + } + interp.add_value_constraint([0.5, 0.5, 0.5], f([0.5, 0.5, 0.5]), 100.0); + interp.add_value_constraint([7.5, 7.5, 7.5], f([7.5, 7.5, 7.5]), 100.0); + + let result = interp.solve(4000, 1e-11); + assert!(result.converged, "iterations={}", result.iterations); + + let query = [[3.0, 3.0, 3.0], [8.0, 0.0, 8.0], [0.0, 8.0, 0.0]]; + let predicted = interp.evaluate(&result.x, &query); + for (p, v) in query.iter().zip(predicted.iter()) { + assert!( + (v - f(*p)).abs() < 5e-2, + "point={p:?} predicted={v} expected={}", + f(*p) + ); + } + } + + /// Recover a plane from norm constraints: unlike gradient-orthogonal + /// constraints, a norm constraint is non-homogeneous (it targets the + /// actual gradient components), so a single norm constraint plus a + /// single value pin fully (and uniquely) determines a 3D affine field. + #[test] + fn recovers_plane_from_norm_constraints() { + // The default border regularisation is on -- see the comment in + // `recovers_plane_from_gradient_constraints_plus_sparse_value_pins`. + let gradient = [2.0, -1.0, 0.5]; + let f = |p: [f64; 3]| 1.0 + gradient[0] * p[0] + gradient[1] * p[1] + gradient[2] * p[2]; + let grid = make_grid(); + let mut interp = FiniteDifferenceInterpolator::new(grid); + + interp.add_norm_constraint([4.3, 4.3, 4.3], gradient, 200.0); + interp.add_value_constraint([0.5, 0.5, 0.5], f([0.5, 0.5, 0.5]), 100.0); + + let result = interp.solve(4000, 1e-11); + assert!(result.converged, "iterations={}", result.iterations); + + let query = [[3.0, 3.0, 3.0], [8.0, 0.0, 8.0], [0.0, 8.0, 0.0]]; + let predicted = interp.evaluate(&result.x, &query); + for (p, v) in query.iter().zip(predicted.iter()) { + assert!( + (v - f(*p)).abs() < 5e-2, + "point={p:?} predicted={v} expected={}", + f(*p) + ); + } + } + + /// A tangent constraint should measurably zero the directional + /// derivative at its point, even when that direction is *not* + /// orthogonal to the field's natural (unconstrained) gradient -- i.e. + /// the constraint has to actually do work, not just agree with the + /// data's existing shape. + #[test] + fn tangent_constraint_zeroes_directional_derivative() { + // Border regularisation disabled -- see the comment in + // `recovers_plane_from_gradient_constraints_plus_sparse_value_pins`. + let f = |p: [f64; 3]| 1.0 + 2.0 * p[0] - p[1] + 0.5 * p[2]; + let grid = make_grid(); + let mut interp = FiniteDifferenceInterpolator::new(grid).with_border_regularisation( + BorderRegularisationWeights { + dx: 0.0, + dy: 0.0, + dz: 0.0, + }, + ); + let samples = [ + [0.5, 0.5, 0.5], + [7.5, 1.0, 2.0], + [2.0, 6.0, 0.2], + [4.0, 4.0, 7.0], + [1.0, 7.5, 7.5], + [6.0, 2.0, 6.0], + ]; + for p in samples { + interp.add_value_constraint(p, f(p), 20.0); + } + // The field's natural gradient is (2, -1, 0.5), not orthogonal to + // (1, 1, 1) -- so constraining the directional derivative along + // (1, 1, 1) to be zero genuinely fights the data. `point` is + // deliberately not on an exact grid node: trilinear fields are only + // C0 (value-continuous) across cell boundaries, so a finite + // difference straddling one would mix two different cells' gradients + // instead of measuring the single cell the constraint actually + // targets. + let point = [4.3, 4.3, 4.3]; + let v = [1.0, 1.0, 1.0]; + interp.add_tangent_constraint(point, v, 500.0); + + let result = interp.solve(4000, 1e-11); + assert!(result.converged, "iterations={}", result.iterations); + + // Numerically estimate the directional derivative of the *solved* + // field along v at `point` and confirm it's near zero. + let eps = 1e-3; + let v_unit = { + let n = norm3(v); + scale3(v, 1.0 / n) + }; + let p_plus = [ + point[0] + eps * v_unit[0], + point[1] + eps * v_unit[1], + point[2] + eps * v_unit[2], + ]; + let p_minus = [ + point[0] - eps * v_unit[0], + point[1] - eps * v_unit[1], + point[2] - eps * v_unit[2], + ]; + let vals = interp.evaluate(&result.x, &[p_plus, p_minus]); + let directional_derivative = (vals[0] - vals[1]) / (2.0 * eps); + assert!( + directional_derivative.abs() < 1e-2, + "directional_derivative={directional_derivative}" + ); + } + + /// Two points sharing an interface id (with no other constraint forcing + /// them apart) should converge to (numerically) equal solved values. + #[test] + fn interface_constraint_pair_converges_to_equal_values() { + let grid = make_grid(); + let mut interp = FiniteDifferenceInterpolator::new(grid); + // Anchor the field away from the interface pair so the system isn't + // entirely homogeneous (which would trivially solve to all zeros). + interp.add_value_constraint([0.5, 0.5, 0.5], 3.0, 50.0); + + let a = [6.0, 2.0, 3.0]; + let b = [2.0, 6.0, 5.0]; + interp.add_interface_constraints(&[a, b], &[7.0, 7.0], 500.0); + + let result = interp.solve(4000, 1e-11); + assert!(result.converged, "iterations={}", result.iterations); + + let vals = interp.evaluate(&result.x, &[a, b]); + assert!( + (vals[0] - vals[1]).abs() < 1e-3, + "a={} b={}", + vals[0], + vals[1] + ); + } + + /// Border regularisation should measurably change boundary-node values + /// relative to having it switched off, for an otherwise-identical setup + /// whose value data doesn't happen to already look like an affine field + /// out to the boundary (so the border rows have something to disagree + /// with the unregularised interior fit about). + #[test] + fn border_regularisation_changes_boundary_values() { + let grid = make_grid(); + let build = |border_weight: f64| { + let mut interp = FiniteDifferenceInterpolator::new(StructuredGrid3D::new( + grid.origin, + grid.nsteps, + grid.step_vector, + )) + .with_border_regularisation(BorderRegularisationWeights { + dx: border_weight, + dy: border_weight, + dz: border_weight, + }); + // A handful of interior samples from a distinctly non-affine + // field, so the boundary is under-determined by data alone and + // sensitive to whatever regularisation is in play. + let f = |p: [f64; 3]| ((p[0] * 0.6).sin() * (p[1] * 0.5).cos() * 4.0) + p[2] * 0.3; + for p in [ + [2.0, 2.0, 2.0], + [6.0, 3.0, 5.0], + [3.0, 6.0, 2.0], + [5.0, 5.0, 6.0], + ] { + interp.add_value_constraint(p, f(p), 50.0); + } + let result = interp.solve(4000, 1e-10); + assert!(result.converged, "iterations={}", result.iterations); + result.x + }; + + let with_border = build(5.0); + let without_border = build(0.0); + + // Compare values at a corner node. A border row on layer 1 has the same + // taps as the interior curvature row of that layer, so it adds only + // weight there. The face and edge nodes are also reached by interior + // rows. The corner nodes are reached by the border rows alone. + let boundary_node = grid.index(0, 0, 0); + let diff = (with_border[boundary_node] - without_border[boundary_node]).abs(); + assert!( + diff > 1e-6, + "expected border regularisation to change boundary node value, diff={diff}" + ); + } + + // ---- Inequality constraint row shapes ---- + + /// A value-bound inequality row at an exact grid node (so trilinear + /// interpolation collapses to a single active corner with coefficient + /// `1.0`, i.e. `norm == 1`) with `weight == 1.0` should store `coefs` + /// and bounds completely unscaled (`scale = weight / norm == 1`), so + /// `apply_inequality_operator` simply returns `x` at that node, and + /// `inequality_bounds` returns the bounds verbatim. + #[test] + fn inequality_value_row_matches_unit_corner_at_exact_node() { + let grid = make_grid(); + let mut interp = FiniteDifferenceInterpolator::new(grid); + let node = [3.0, 3.0, 3.0]; + interp.add_value_inequality_constraint(node, 0.2, 0.8, 1.0); + assert_eq!(interp.n_value_inequality_constraints(), 1); + assert_eq!(interp.n_inequality_constraints(), 1); + + let (lower, upper) = interp.inequality_bounds(); + assert!((lower[0] - 0.2).abs() < 1e-12, "lower={}", lower[0]); + assert!((upper[0] - 0.8).abs() < 1e-12, "upper={}", upper[0]); + + let mut x = vec![0.0; interp.dof()]; + let node_index = interp.grid.index(3, 3, 3); + x[node_index] = 0.5; + let qx = interp.apply_inequality_operator(&x); + assert_eq!(qx.len(), 1); + assert!((qx[0] - 0.5).abs() < 1e-12, "qx={}", qx[0]); + } + + /// A pairwise-ordering inequality row at two exact grid nodes: raw + /// coefficients are `[1, -1]` (8-wide one-hot each), so `norm == + /// sqrt(2)`. With `weight == 1.0`, `scale = 1 / sqrt(2)`, so the row + /// value for `x_a - x_b == 2` should come out to `2 / sqrt(2)`, and the + /// stored bounds should be the raw bounds scaled by the same factor. + #[test] + fn inequality_pair_row_matches_scaled_difference_at_exact_nodes() { + let grid = make_grid(); + let mut interp = FiniteDifferenceInterpolator::new(grid); + let a = [2.0, 2.0, 2.0]; + let b = [6.0, 6.0, 6.0]; + interp.add_inequality_pair_constraint(a, b, -5.0, 0.0, 1.0); + assert_eq!(interp.n_inequality_pair_constraints(), 1); + assert_eq!(interp.n_inequality_constraints(), 1); + + let scale = 1.0 / std::f64::consts::SQRT_2; + let (lower, upper) = interp.inequality_bounds(); + assert!( + (lower[0] - (-5.0 * scale)).abs() < 1e-9, + "lower={}", + lower[0] + ); + assert!( + (upper[0] - (0.0 * scale)).abs() < 1e-9, + "upper={}", + upper[0] + ); + + let mut x = vec![0.0; interp.dof()]; + x[interp.grid.index(2, 2, 2)] = 3.0; + x[interp.grid.index(6, 6, 6)] = 1.0; + let qx = interp.apply_inequality_operator(&x); + assert_eq!(qx.len(), 1); + let expected = (3.0 - 1.0) * scale; + assert!( + (qx[0] - expected).abs() < 1e-9, + "qx={} expected={}", + qx[0], + expected + ); + } + + /// Both inequality constraint constructors must silently drop + /// out-of-grid points (matching every other constraint family), and a + /// non-positive weight (which would need to flip the bound direction to + /// stay meaningful once folded into `coefs`/bounds together). + #[test] + fn inequality_constraints_outside_grid_or_nonpositive_weight_are_dropped() { + let grid = make_grid(); + let mut interp = FiniteDifferenceInterpolator::new(grid); + + interp.add_value_inequality_constraint([-5.0, 0.0, 0.0], 0.0, 1.0, 1.0); + assert_eq!(interp.n_value_inequality_constraints(), 0); + interp.add_value_inequality_constraint([3.0, 3.0, 3.0], 0.0, 1.0, 0.0); + assert_eq!(interp.n_value_inequality_constraints(), 0); + interp.add_value_inequality_constraint([3.0, 3.0, 3.0], 0.0, 1.0, -1.0); + assert_eq!(interp.n_value_inequality_constraints(), 0); + + interp.add_inequality_pair_constraint([-5.0, 0.0, 0.0], [2.0, 2.0, 2.0], -1.0, 0.0, 1.0); + assert_eq!(interp.n_inequality_pair_constraints(), 0); + interp.add_inequality_pair_constraint([2.0, 2.0, 2.0], [-5.0, 0.0, 0.0], -1.0, 0.0, 1.0); + assert_eq!(interp.n_inequality_pair_constraints(), 0); + interp.add_inequality_pair_constraint([2.0, 2.0, 2.0], [6.0, 6.0, 6.0], -1.0, 0.0, 0.0); + assert_eq!(interp.n_inequality_pair_constraints(), 0); + } + + // ---- ADMM solver ---- + + /// When there are no inequality constraints at all, `solve_admm` should + /// delegate straight to `solve` -- same problem, same answer (up to the + /// usual floating-point wobble from `solve`'s internal rayon-parallel + /// reductions not guaranteeing identical summation order between two + /// separate calls -- see `dot` in `cg.rs` -- rather than any real + /// difference in what's being solved), no ADMM overhead paid for having + /// nothing to project. + #[test] + fn solve_admm_with_no_inequality_constraints_matches_plain_solve() { + let grid = make_grid(); + let mut interp = FiniteDifferenceInterpolator::new(grid); + interp.add_value_constraint([2.0, 2.0, 2.0], 1.0, 10.0); + interp.add_value_constraint([6.0, 3.0, 4.0], -0.5, 10.0); + interp.add_gradient_constraint([4.0, 4.0, 4.0], [1.0, 0.2, -0.1], 2.0); + + assert_eq!(interp.n_inequality_constraints(), 0); + + let plain = interp.solve(4000, 1e-11); + let admm = interp.solve_admm(1.0, 50, 4000, 1e-11, 1e-6, 1e-6); + + assert!(plain.converged); + assert!(admm.converged); + assert_eq!( + admm.outer_iterations, 0, + "expected the no-inequality fast path to skip the outer loop" + ); + assert_eq!(plain.x.len(), admm.x.len()); + for (a, b) in plain.x.iter().zip(admm.x.iter()) { + assert!( + (a - b).abs() < 1e-8, + "solve_admm should delegate exactly to solve() when there are no inequality rows: a={a} b={b}" + ); + } + } + + /// End-to-end: a value-inequality upper bound that's genuinely *active* + /// -- the unconstrained equality fit at the constrained point + /// overshoots the upper bound -- and `solve_admm` must pull it back down + /// to (at most, within tolerance) that bound. + #[test] + fn solve_admm_enforces_active_value_upper_bound() { + let grid = make_grid(); + let point = [4.0, 4.0, 4.0]; + let upper_bound = 2.0; + + // Unconstrained: a single strong value pin at 10.0 (far above the + // bound) plus default (nonzero) regularisation, so the equality-only + // solve settles near 10.0 at that point -- well past `upper_bound`. + let mut unconstrained = FiniteDifferenceInterpolator::new(StructuredGrid3D::new( + grid.origin, + grid.nsteps, + grid.step_vector, + )); + unconstrained.add_value_constraint(point, 10.0, 100.0); + let baseline = unconstrained.solve(4000, 1e-10); + assert!(baseline.converged); + let baseline_value = unconstrained.evaluate(&baseline.x, &[point])[0]; + assert!( + baseline_value > upper_bound + 0.5, + "expected the unconstrained fit to overshoot the bound, got {baseline_value}" + ); + + // Same data constraint, but now with a value-inequality upper bound + // added and solved via ADMM. + let mut constrained = FiniteDifferenceInterpolator::new(StructuredGrid3D::new( + grid.origin, + grid.nsteps, + grid.step_vector, + )); + constrained.add_value_constraint(point, 10.0, 100.0); + constrained.add_value_inequality_constraint(point, f64::NEG_INFINITY, upper_bound, 1.0); + + // `rho` needs to be comparable to (or larger than) the competing + // equality constraint's own stiffness at this dof (`weight^2 = + // 100^2 = 10_000`, from `add_value_constraint`'s weighting + // convention) for classical fixed-rho ADMM to pull the fit back to + // the bound in a reasonable number of outer iterations -- with `rho` + // much smaller than that (e.g. `rho = 1`), the primal residual + // still shrinks every iteration (ADMM is still converging) but only + // by a fraction of a percent per iteration, since the x-update + // barely reacts to the box-projected `z`/`u` correction next to such + // a stiff competing equality pull. This is expected, textbook + // behaviour for constant-rho ADMM (see Boyd et al. section 3.4 on + // rho selection), not a bug -- an adaptive-rho schedule would pick + // this up automatically, which is exactly the kind of refinement + // explicitly descoped here (see `solve_admm`'s docs). + let result = constrained.solve_admm(5000.0, 2000, 4000, 1e-10, 1e-6, 1e-6); + assert!( + result.converged, + "admm did not converge: outer_iterations={}", + result.outer_iterations + ); + + let constrained_value = constrained.evaluate(&result.x, &[point])[0]; + assert!( + constrained_value <= upper_bound + 1e-2, + "expected ADMM to enforce the upper bound, got {constrained_value} (bound {upper_bound})" + ); + } + + /// End-to-end: a pairwise-ordering inequality that's genuinely active -- + /// two value pins that, left alone, put point A *above* point B, while + /// the inequality demands `f(A) - f(B) <= 0` (A must not exceed B, the + /// stratigraphic-ordering shape). ADMM must pull the pair back into the + /// required order. + #[test] + fn solve_admm_enforces_active_pairwise_ordering_bound() { + let grid = make_grid(); + let a = [2.0, 2.0, 2.0]; + let b = [6.0, 6.0, 6.0]; + + let mut interp = FiniteDifferenceInterpolator::new(grid); + interp.add_value_constraint(a, 5.0, 100.0); + interp.add_value_constraint(b, 1.0, 100.0); + // Sanity: unconstrained, a should indeed end up above b. + let baseline = interp.solve(4000, 1e-10); + assert!(baseline.converged); + let baseline_vals = interp.evaluate(&baseline.x, &[a, b]); + assert!( + baseline_vals[0] > baseline_vals[1] + 0.5, + "expected the unconstrained fit to have a > b, got a={} b={}", + baseline_vals[0], + baseline_vals[1] + ); + + interp.add_inequality_pair_constraint(a, b, f64::NEG_INFINITY, 0.0, 1.0); + // See the `rho` sizing comment in `solve_admm_enforces_active_value_upper_bound`: + // it needs to be comparable to the competing value pins' own + // stiffness (`weight^2 = 100^2 = 10_000`) for reasonably fast + // fixed-rho ADMM convergence. + let result = interp.solve_admm(5000.0, 2000, 4000, 1e-10, 1e-6, 1e-6); + assert!( + result.converged, + "admm did not converge: outer_iterations={}", + result.outer_iterations + ); + + let vals = interp.evaluate(&result.x, &[a, b]); + assert!( + vals[0] <= vals[1] + 1e-2, + "expected ADMM to enforce a <= b, got a={} b={}", + vals[0], + vals[1] + ); + } + + // ---- Active-Set CG (`solve_active_set_cg`) ---- + + #[test] + fn solve_active_set_cg_with_no_inequality_constraints_matches_plain_solve() { + let grid = make_grid(); + let mut interp = FiniteDifferenceInterpolator::new(grid); + interp.add_value_constraint([2.0, 2.0, 2.0], 1.0, 10.0); + interp.add_value_constraint([6.0, 3.0, 4.0], -0.5, 10.0); + interp.add_gradient_constraint([4.0, 4.0, 4.0], [1.0, 0.2, -0.1], 2.0); + + assert_eq!(interp.n_inequality_constraints(), 0); + + let plain = interp.solve(4000, 1e-11); + let as_cg = interp.solve_active_set_cg(50, 4000, 1e-11, 1e-9); + + assert!(plain.converged); + assert!(as_cg.converged); + assert_eq!( + as_cg.outer_iterations, 0, + "expected the no-inequality fast path to skip the working-set loop" + ); + assert_eq!(plain.x.len(), as_cg.x.len()); + for (a, b) in plain.x.iter().zip(as_cg.x.iter()) { + assert!( + (a - b).abs() < 1e-8, + "solve_active_set_cg should delegate exactly to solve() when there are no inequality rows: a={a} b={b}" + ); + } + } + + /// A value-inequality constraint whose bound is never actually violated + /// by the unconstrained fit: the working set should end up empty and the + /// result should match the plain equality solve. + #[test] + fn solve_active_set_cg_matches_plain_solve_when_bound_is_not_violated() { + let grid = make_grid(); + let point = [4.0, 4.0, 4.0]; + + let mut interp = FiniteDifferenceInterpolator::new(grid); + interp.add_value_constraint(point, 1.0, 10.0); + interp.add_value_inequality_constraint(point, f64::NEG_INFINITY, 100.0, 1.0); + + let plain = interp.solve(4000, 1e-11); + let as_cg = interp.solve_active_set_cg(50, 4000, 1e-11, 1e-9); + + assert!(plain.converged); + assert!(as_cg.converged); + assert_eq!( + as_cg.outer_iterations, 0, + "an always-satisfied bound should never enter the working set" + ); + for (a, b) in plain.x.iter().zip(as_cg.x.iter()) { + assert!((a - b).abs() < 1e-8, "a={a} b={b}"); + } + } + + /// Same scenario as `solve_admm_enforces_active_value_upper_bound`, but + /// via `solve_active_set_cg` -- which, unlike ADMM, must land on the + /// bound to (near) floating-point precision, not just within a loose + /// residual tolerance. + #[test] + fn solve_active_set_cg_enforces_active_value_upper_bound() { + let grid = make_grid(); + let point = [4.0, 4.0, 4.0]; + let upper_bound = 2.0; + + let mut unconstrained = FiniteDifferenceInterpolator::new(StructuredGrid3D::new( + grid.origin, + grid.nsteps, + grid.step_vector, + )); + unconstrained.add_value_constraint(point, 10.0, 100.0); + let baseline = unconstrained.solve(4000, 1e-10); + assert!(baseline.converged); + let baseline_value = unconstrained.evaluate(&baseline.x, &[point])[0]; + assert!( + baseline_value > upper_bound + 0.5, + "expected the unconstrained fit to overshoot the bound, got {baseline_value}" + ); + + let mut constrained = FiniteDifferenceInterpolator::new(StructuredGrid3D::new( + grid.origin, + grid.nsteps, + grid.step_vector, + )); + constrained.add_value_constraint(point, 10.0, 100.0); + constrained.add_value_inequality_constraint(point, f64::NEG_INFINITY, upper_bound, 1.0); + + // Note: `inner_tol` here is looser than the `1e-11` used elsewhere + // in this test module (e.g. `solve_admm`'s tests) -- the projected + // CG's null-space projection does a little extra floating-point + // work every iteration compared to plain Jacobi-preconditioned CG, + // which raises its practically-reachable noise floor slightly. The + // bound-contact precision this method actually promises comes from + // `active_set_restore_feasibility`'s exact affine correction, not + // from how tight `inner_tol` is -- see the assertion below, which + // still checks contact to `1e-6`. + let result = constrained.solve_active_set_cg(50, 4000, 1e-8, 1e-9); + assert!( + result.converged, + "active-set CG did not converge: outer_iterations={}", + result.outer_iterations + ); + + let constrained_value = constrained.evaluate(&result.x, &[point])[0]; + assert!( + (constrained_value - upper_bound).abs() < 1e-6, + "expected active-set CG to land on the bound to near machine precision, got {constrained_value} (bound {upper_bound})" + ); + } + + /// Same scenario as `solve_admm_enforces_active_pairwise_ordering_bound`, + /// via `solve_active_set_cg`. + #[test] + fn solve_active_set_cg_enforces_active_pairwise_ordering_bound() { + let grid = make_grid(); + let a = [2.0, 2.0, 2.0]; + let b = [6.0, 6.0, 6.0]; + + let mut interp = FiniteDifferenceInterpolator::new(grid); + interp.add_value_constraint(a, 5.0, 100.0); + interp.add_value_constraint(b, 1.0, 100.0); + let baseline = interp.solve(4000, 1e-10); + assert!(baseline.converged); + let baseline_vals = interp.evaluate(&baseline.x, &[a, b]); + assert!( + baseline_vals[0] > baseline_vals[1] + 0.5, + "expected the unconstrained fit to have a > b, got a={} b={}", + baseline_vals[0], + baseline_vals[1] + ); + + interp.add_inequality_pair_constraint(a, b, f64::NEG_INFINITY, 0.0, 1.0); + // See the `inner_tol` sizing note on + // `solve_active_set_cg_enforces_active_value_upper_bound`. + let result = interp.solve_active_set_cg(50, 4000, 1e-8, 1e-9); + assert!( + result.converged, + "active-set CG did not converge: outer_iterations={}", + result.outer_iterations + ); + + let vals = interp.evaluate(&result.x, &[a, b]); + assert!( + vals[0] <= vals[1] + 1e-6, + "expected active-set CG to enforce a <= b (near-exactly), got a={} b={}", + vals[0], + vals[1] + ); + } + + /// Two simultaneously active bounds (one value-bound, one pairwise) so + /// the working set grows past one row and the Lagrange-multiplier + /// release check gets exercised alongside step truncation. + #[test] + fn solve_active_set_cg_enforces_multiple_simultaneous_active_bounds() { + let grid = make_grid(); + let point = [4.0, 4.0, 4.0]; + let upper_bound = 3.0; + let a = [2.0, 2.0, 2.0]; + let b = [6.0, 6.0, 6.0]; + + let mut interp = FiniteDifferenceInterpolator::new(grid); + interp.add_value_constraint(point, 10.0, 100.0); + interp.add_value_constraint(a, 5.0, 100.0); + interp.add_value_constraint(b, 1.0, 100.0); + interp.add_value_inequality_constraint(point, f64::NEG_INFINITY, upper_bound, 1.0); + interp.add_inequality_pair_constraint(a, b, f64::NEG_INFINITY, 0.0, 1.0); + + // See the `inner_tol` sizing note on + // `solve_active_set_cg_enforces_active_value_upper_bound`. + let result = interp.solve_active_set_cg(50, 4000, 1e-8, 1e-9); + assert!( + result.converged, + "active-set CG did not converge: outer_iterations={}", + result.outer_iterations + ); + + let point_val = interp.evaluate(&result.x, &[point])[0]; + let vals = interp.evaluate(&result.x, &[a, b]); + assert!( + (point_val - upper_bound).abs() < 1e-6, + "expected the value bound at {point_val} (bound {upper_bound})" + ); + assert!( + vals[0] <= vals[1] + 1e-6, + "expected a <= b, got a={} b={}", + vals[0], + vals[1] + ); + } + + // ---- Hybrid solve (`solve_hybrid`) ---- + + #[test] + fn solve_hybrid_with_no_inequality_constraints_matches_plain_solve() { + let grid = make_grid(); + let mut interp = FiniteDifferenceInterpolator::new(grid); + interp.add_value_constraint([2.0, 2.0, 2.0], 1.0, 10.0); + interp.add_gradient_constraint([4.0, 4.0, 4.0], [1.0, 0.2, -0.1], 2.0); + + assert_eq!(interp.n_inequality_constraints(), 0); + + let plain = interp.solve(4000, 1e-11); + let hybrid = interp.solve_hybrid(1.0, 15, 4000, 1e-10, 50, 4000, 1e-11, 1e-9); + + assert!(plain.converged); + assert!(hybrid.converged); + assert_eq!(hybrid.admm_warmup_iterations, 0); + assert_eq!(hybrid.polish_outer_iterations, 0); + for (a, b) in plain.x.iter().zip(hybrid.x.iter()) { + assert!( + (a - b).abs() < 1e-8, + "solve_hybrid should delegate exactly to solve() when there are no inequality rows: a={a} b={b}" + ); + } + } + + /// Same scenario as `solve_active_set_cg_enforces_active_value_upper_bound`, + /// via the two-stage hybrid solve: a short ADMM warm-up followed by an + /// active-set polish. The polish stage must still land on the bound to + /// near machine precision, even though the ADMM warm-up alone only gets + /// close. + #[test] + fn solve_hybrid_enforces_active_value_upper_bound_to_machine_precision() { + let grid = make_grid(); + let point = [4.0, 4.0, 4.0]; + let upper_bound = 2.0; + + let mut constrained = FiniteDifferenceInterpolator::new(StructuredGrid3D::new( + grid.origin, + grid.nsteps, + grid.step_vector, + )); + constrained.add_value_constraint(point, 10.0, 100.0); + constrained.add_value_inequality_constraint(point, f64::NEG_INFINITY, upper_bound, 1.0); + + // A short, deliberately loose ADMM warm-up (rho comparable to the + // competing equality weight^2, per the sizing note on + // `solve_admm_enforces_active_value_upper_bound`) followed by an + // active-set polish that must clean up whatever ADMM left behind. + // See the `inner_tol` sizing note on + // `solve_active_set_cg_enforces_active_value_upper_bound` for why + // `polish_tol` is looser than `1e-11`. + let result = constrained.solve_hybrid(5000.0, 15, 4000, 1e-6, 50, 4000, 1e-8, 1e-6); + assert!( + result.converged, + "hybrid polish did not converge: outer_iterations={}", + result.polish_outer_iterations + ); + + let constrained_value = constrained.evaluate(&result.x, &[point])[0]; + assert!( + (constrained_value - upper_bound).abs() < 1e-6, + "expected the hybrid solve to land on the bound to near machine precision, got {constrained_value} (bound {upper_bound})" + ); + } + + /// The hybrid solve's ADMM warm-up is a fixed budget, not a solve to + /// convergence -- even `admm_warmup_iter = 0` (skip ADMM entirely) must + /// still land on the bound via the active-set polish alone, since + /// `active_set_cg_core` re-derives feasibility from scratch regardless + /// of how good the hand-off point is. + #[test] + fn solve_hybrid_converges_with_zero_admm_warmup_iterations() { + let grid = make_grid(); + let a = [2.0, 2.0, 2.0]; + let b = [6.0, 6.0, 6.0]; + + let mut interp = FiniteDifferenceInterpolator::new(grid); + interp.add_value_constraint(a, 5.0, 100.0); + interp.add_value_constraint(b, 1.0, 100.0); + interp.add_inequality_pair_constraint(a, b, f64::NEG_INFINITY, 0.0, 1.0); + + // See the `inner_tol` sizing note on + // `solve_active_set_cg_enforces_active_value_upper_bound`. + let result = interp.solve_hybrid(1.0, 0, 4000, 1e-6, 50, 4000, 1e-8, 1e-9); + assert!( + result.converged, + "hybrid polish did not converge: outer_iterations={}", + result.polish_outer_iterations + ); + assert_eq!(result.admm_warmup_iterations, 0); + + let vals = interp.evaluate(&result.x, &[a, b]); + assert!( + vals[0] <= vals[1] + 1e-6, + "expected a <= b, got a={} b={}", + vals[0], + vals[1] + ); + } + + // ---- Cross-solver comparison (ADMM vs Active-Set CG vs Hybrid) ---- + // + // All three solvers minimise the *same* strictly convex QP (`N`/`b` from + // `ridge_factor > 0`-damped equality constraints + regularisation, `Q`/ + // bounds from the inequality rows), which has a unique global optimum -- + // so, given tight enough settings, all three must agree on that optimum + // regardless of which algorithm found it. These tests exploit that: they + // solve one interpolator instance with all three (`solve_*` all take + // `&self`, so the same instance can be reused) and check both that the + // solved *fields* agree and that ADMM's bound contact is measurably + // looser than Active-Set CG's/Hybrid's exact contact -- the trade-off + // `Inequalities.md`'s decision matrix describes. + + fn max_abs_diff(a: &[f64], b: &[f64]) -> f64 { + a.iter() + .zip(b.iter()) + .map(|(x, y)| (x - y).abs()) + .fold(0.0, f64::max) + } + + #[test] + fn all_three_solvers_agree_on_active_value_upper_bound() { + let grid = make_grid(); + let point = [4.0, 4.0, 4.0]; + let upper_bound = 2.0; + + let mut interp = FiniteDifferenceInterpolator::new(StructuredGrid3D::new( + grid.origin, + grid.nsteps, + grid.step_vector, + )); + interp.add_value_constraint(point, 10.0, 100.0); + interp.add_value_inequality_constraint(point, f64::NEG_INFINITY, upper_bound, 1.0); + + // Same `rho`/iteration sizing as `solve_admm_enforces_active_value_upper_bound`. + let admm = interp.solve_admm(5000.0, 2000, 4000, 1e-10, 1e-8, 1e-8); + let as_cg = interp.solve_active_set_cg(50, 4000, 1e-8, 1e-9); + let hybrid = interp.solve_hybrid(5000.0, 15, 4000, 1e-6, 50, 4000, 1e-8, 1e-6); + + assert!(admm.converged, "admm did not converge"); + assert!(as_cg.converged, "active-set CG did not converge"); + assert!(hybrid.converged, "hybrid did not converge"); + + let admm_err = (interp.evaluate(&admm.x, &[point])[0] - upper_bound).abs(); + let as_cg_err = (interp.evaluate(&as_cg.x, &[point])[0] - upper_bound).abs(); + let hybrid_err = (interp.evaluate(&hybrid.x, &[point])[0] - upper_bound).abs(); + assert!( + as_cg_err < 1e-6, + "expected active-set CG's bound contact to be near-exact, got err={as_cg_err}" + ); + assert!( + hybrid_err < 1e-6, + "expected hybrid's bound contact to be near-exact, got err={hybrid_err}" + ); + assert!( + admm_err > as_cg_err * 10.0, + "expected ADMM's asymptotic contact to be measurably looser than active-set CG's exact contact, got admm_err={admm_err} as_cg_err={as_cg_err}" + ); + + // All three minimise the same QP, so the whole field -- not just the + // constrained point -- should agree closely, bounded by ADMM's own + // (looser) convergence tolerance. + assert!( + max_abs_diff(&admm.x, &as_cg.x) < 1e-2, + "admm/active-set-cg fields disagree by more than expected: max diff={}", + max_abs_diff(&admm.x, &as_cg.x) + ); + assert!( + max_abs_diff(&admm.x, &hybrid.x) < 1e-2, + "admm/hybrid fields disagree by more than expected: max diff={}", + max_abs_diff(&admm.x, &hybrid.x) + ); + assert!( + max_abs_diff(&as_cg.x, &hybrid.x) < 1e-2, + "active-set-cg/hybrid fields disagree by more than expected: max diff={}", + max_abs_diff(&as_cg.x, &hybrid.x) + ); + } + + #[test] + fn all_three_solvers_agree_on_active_pairwise_ordering_bound() { + let grid = make_grid(); + let a = [2.0, 2.0, 2.0]; + let b = [6.0, 6.0, 6.0]; + + let mut interp = FiniteDifferenceInterpolator::new(grid); + interp.add_value_constraint(a, 5.0, 100.0); + interp.add_value_constraint(b, 1.0, 100.0); + interp.add_inequality_pair_constraint(a, b, f64::NEG_INFINITY, 0.0, 1.0); + + let admm = interp.solve_admm(5000.0, 2000, 4000, 1e-10, 1e-8, 1e-8); + let as_cg = interp.solve_active_set_cg(50, 4000, 1e-8, 1e-9); + let hybrid = interp.solve_hybrid(5000.0, 15, 4000, 1e-6, 50, 4000, 1e-8, 1e-6); + + assert!(admm.converged, "admm did not converge"); + assert!(as_cg.converged, "active-set CG did not converge"); + assert!(hybrid.converged, "hybrid did not converge"); + + let admm_vals = interp.evaluate(&admm.x, &[a, b]); + let as_cg_vals = interp.evaluate(&as_cg.x, &[a, b]); + let hybrid_vals = interp.evaluate(&hybrid.x, &[a, b]); + + let admm_err = (admm_vals[0] - admm_vals[1]).max(0.0); + let as_cg_err = (as_cg_vals[0] - as_cg_vals[1]).max(0.0); + let hybrid_err = (hybrid_vals[0] - hybrid_vals[1]).max(0.0); + assert!( + as_cg_err < 1e-6, + "expected active-set CG's a<=b contact to be near-exact, got err={as_cg_err}" + ); + assert!( + hybrid_err < 1e-6, + "expected hybrid's a<=b contact to be near-exact, got err={hybrid_err}" + ); + assert!( + admm_err <= 1e-2, + "expected ADMM to satisfy a<=b within its own (looser) tolerance, got err={admm_err}" + ); + + assert!( + max_abs_diff(&admm.x, &as_cg.x) < 1e-2, + "admm/active-set-cg fields disagree by more than expected: max diff={}", + max_abs_diff(&admm.x, &as_cg.x) + ); + assert!( + max_abs_diff(&admm.x, &hybrid.x) < 1e-2, + "admm/hybrid fields disagree by more than expected: max diff={}", + max_abs_diff(&admm.x, &hybrid.x) + ); + assert!( + max_abs_diff(&as_cg.x, &hybrid.x) < 1e-2, + "active-set-cg/hybrid fields disagree by more than expected: max diff={}", + max_abs_diff(&as_cg.x, &hybrid.x) + ); + } + + /// Same multi-constraint scenario as + /// `solve_active_set_cg_enforces_multiple_simultaneous_active_bounds`, + /// but cross-checking all three solvers agree with each other on both + /// active bounds simultaneously. + #[test] + fn all_three_solvers_agree_with_multiple_simultaneous_active_bounds() { + let grid = make_grid(); + let point = [4.0, 4.0, 4.0]; + let upper_bound = 3.0; + let a = [2.0, 2.0, 2.0]; + let b = [6.0, 6.0, 6.0]; + + let mut interp = FiniteDifferenceInterpolator::new(grid); + interp.add_value_constraint(point, 10.0, 100.0); + interp.add_value_constraint(a, 5.0, 100.0); + interp.add_value_constraint(b, 1.0, 100.0); + interp.add_value_inequality_constraint(point, f64::NEG_INFINITY, upper_bound, 1.0); + interp.add_inequality_pair_constraint(a, b, f64::NEG_INFINITY, 0.0, 1.0); + + let admm = interp.solve_admm(5000.0, 2000, 4000, 1e-10, 1e-8, 1e-8); + let as_cg = interp.solve_active_set_cg(50, 4000, 1e-8, 1e-9); + let hybrid = interp.solve_hybrid(5000.0, 15, 4000, 1e-6, 50, 4000, 1e-8, 1e-6); + + assert!(admm.converged, "admm did not converge"); + assert!(as_cg.converged, "active-set CG did not converge"); + assert!(hybrid.converged, "hybrid did not converge"); + + for (name, x) in [ + ("admm", &admm.x), + ("as_cg", &as_cg.x), + ("hybrid", &hybrid.x), + ] { + let point_val = interp.evaluate(x, &[point])[0]; + let vals = interp.evaluate(x, &[a, b]); + assert!( + point_val <= upper_bound + 1e-2, + "{name}: expected the value bound to hold, got {point_val} (bound {upper_bound})" + ); + assert!( + vals[0] <= vals[1] + 1e-2, + "{name}: expected a <= b, got a={} b={}", + vals[0], + vals[1] + ); + } + + assert!( + max_abs_diff(&admm.x, &as_cg.x) < 1e-2, + "admm/active-set-cg fields disagree by more than expected: max diff={}", + max_abs_diff(&admm.x, &as_cg.x) + ); + assert!( + max_abs_diff(&as_cg.x, &hybrid.x) < 1e-2, + "active-set-cg/hybrid fields disagree by more than expected: max diff={}", + max_abs_diff(&as_cg.x, &hybrid.x) + ); + } + + /// When no bound is ever violated, all three solvers (including their + /// no-inequality-rows fast paths) must degenerate to the same plain + /// equality solve. + #[test] + fn all_three_solvers_agree_with_plain_solve_when_no_bound_is_violated() { + let grid = make_grid(); + let point = [4.0, 4.0, 4.0]; + + let mut interp = FiniteDifferenceInterpolator::new(grid); + interp.add_value_constraint(point, 1.0, 10.0); + interp.add_value_inequality_constraint(point, f64::NEG_INFINITY, 100.0, 1.0); + + let plain = interp.solve(4000, 1e-10); + let admm = interp.solve_admm(1.0, 50, 4000, 1e-10, 1e-6, 1e-6); + let as_cg = interp.solve_active_set_cg(50, 4000, 1e-8, 1e-9); + let hybrid = interp.solve_hybrid(1.0, 15, 4000, 1e-6, 50, 4000, 1e-8, 1e-9); + + assert!(plain.converged); + assert!(admm.converged); + assert!(as_cg.converged); + assert!(hybrid.converged); + + for (name, x) in [ + ("admm", &admm.x), + ("as_cg", &as_cg.x), + ("hybrid", &hybrid.x), + ] { + assert!( + max_abs_diff(&plain.x, x) < 1e-6, + "{name} should match the plain equality solve when no bound is ever violated: max diff={}", + max_abs_diff(&plain.x, x) + ); + } + } + + // ---- Fused single-kernel regularisation: bit-for-bit-close equivalence ---- + + fn assert_fields_close(fused: &[f64], unfused: &[f64], grid: &StructuredGrid3D, ctx: &str) { + assert_eq!(fused.len(), unfused.len()); + let scale = unfused.iter().fold(0.0_f64, |m, v| m.max(v.abs())).max(1.0); + for (flat, (a, b)) in fused.iter().zip(unfused.iter()).enumerate() { + let diff = (a - b).abs(); + if diff > 1e-9 * scale { + let (i, j, k) = grid.node_ijk(flat); + panic!( + "{ctx}: fused/unfused mismatch at flat={flat} (i={i},j={j},k={k}): fused={a} unfused={b} diff={diff} scale={scale}" + ); + } + } + } + + /// The fused single-kernel regularisation path (`apply_normal_operator`) + /// must produce the exact same normal-operator output as the reference + /// two-pass computation (`apply_normal_operator_unfused`) at *every* dof + /// -- not just in the deep interior, but also across the boundary shell + /// the fused kernel deliberately skips and the shell-correction pass + /// fixes up. Exercised across several grid sizes (including a tiny one + /// where most/all nodes are shell nodes), several random fields, and a + /// mix of regularisation/border weights (including some zero, to check + /// the "family doesn't contribute" fast paths agree too). + #[test] + fn fused_matches_unfused_various_grids_and_fields() { + let grid_shapes: [[usize; 3]; 4] = [[4, 4, 4], [5, 5, 5], [6, 9, 5], [11, 8, 13]]; + let weight_sets = [ + RegularisationWeights::default(), + RegularisationWeights { + dxx: 2.0, + dyy: 0.5, + dzz: 1.5, + dxy: 0.0, + dxz: 3.0, + dyz: 0.0, + }, + RegularisationWeights { + dxx: 0.0, + dyy: 0.0, + dzz: 0.0, + dxy: 0.0, + dxz: 0.0, + dyz: 0.0, + }, + ]; + let border_weight_sets = [ + BorderRegularisationWeights::default(), + BorderRegularisationWeights { + dx: 0.0, + dy: 2.0, + dz: 0.7, + }, + ]; + + for shape in grid_shapes { + let grid = StructuredGrid3D::new([0.0, 0.0, 0.0], shape, [1.0, 1.0, 1.0]); + for weights in weight_sets { + for border_weights in border_weight_sets { + let mut interp = FiniteDifferenceInterpolator::new(StructuredGrid3D::new( + grid.origin, + grid.nsteps, + grid.step_vector, + )) + .with_regularisation(weights) + .with_border_regularisation(border_weights); + // A couple of scattered value/gradient constraints so the + // data-term diagonal isn't uniformly zero either. + interp.add_value_constraint([1.0, 1.0, 1.0], 0.7, 3.0); + interp.add_gradient_constraint([2.0, 1.0, 0.0], [1.0, 0.3, -0.2], 1.5); + + let n = interp.dof(); + for seed in [1u64, 2u64, 3u64] { + let x: Vec = (0..n) + .map(|i| { + let h = (i as u64) + .wrapping_mul(6364136223846793005u64) + .wrapping_add(seed); + (h % 1000) as f64 / 1000.0 - 0.5 + }) + .collect(); + let fused = interp.apply_normal_operator(&x); + let unfused = interp.apply_normal_operator_unfused(&x); + assert_fields_close( + &fused, + &unfused, + &interp.grid, + &format!( + "shape={shape:?} weights={weights:?} border={border_weights:?} seed={seed}" + ), + ); + } + } + } + } + } + + /// Same equivalence check, but specifically isolated to the boundary + /// shell (dist < 2 from the true edge) on a grid large enough to have a + /// genuine deep interior too -- guards against a regression that + /// happens to look fine in aggregate (e.g. errors at a handful of shell + /// nodes averaging out against a large, correct interior) by checking + /// shell nodes specifically and individually. + #[test] + fn fused_matches_unfused_at_boundary_shell_specifically() { + let grid = StructuredGrid3D::new([0.0, 0.0, 0.0], [12, 10, 9], [1.0, 1.0, 1.0]); + let mut interp = FiniteDifferenceInterpolator::new(grid) + .with_regularisation(RegularisationWeights { + dxx: 1.3, + dyy: 0.6, + dzz: 2.0, + dxy: 1.0, + dxz: 0.4, + dyz: 1.1, + }) + .with_border_regularisation(BorderRegularisationWeights { + dx: 1.7, + dy: 0.9, + dz: 1.2, + }); + interp.add_value_constraint([0.0, 0.0, 0.0], 1.0, 5.0); + interp.add_value_constraint([11.0, 9.0, 8.0], -1.0, 5.0); + + let n = interp.dof(); + let x: Vec = (0..n) + .map(|i| ((i * 53 + 7) % 97) as f64 / 97.0 - 0.5) + .collect(); + let fused = interp.apply_normal_operator(&x); + let unfused = interp.apply_normal_operator_unfused(&x); + + let (nx, ny, nz) = ( + interp.grid.nsteps[0], + interp.grid.nsteps[1], + interp.grid.nsteps[2], + ); + let mut shell_checked = 0; + for flat in 0..n { + let (i, j, k) = interp.grid.node_ijk(flat); + if dist_to_true_edge(&[nx, ny, nz], i, j, k) < 2 { + shell_checked += 1; + let diff = (fused[flat] - unfused[flat]).abs(); + let scale = unfused.iter().fold(0.0_f64, |m, v| m.max(v.abs())).max(1.0); + assert!( + diff < 1e-9 * scale, + "shell mismatch at flat={flat} (i={i},j={j},k={k}): fused={} unfused={} diff={diff}", + fused[flat], + unfused[flat] + ); + } + } + // Sanity: this grid should actually have a nontrivial shell (corners, + // edges, faces), otherwise the test isn't exercising what it claims to. + assert!( + shell_checked > 50, + "expected a substantial boundary shell, got {shell_checked} nodes" + ); + } + + // ---- BlockJacobiX preconditioner ---- + + /// `BlockJacobiX` must converge to (numerically) the same solution as + /// plain Jacobi on the same problem -- confirming it's a valid SPD + /// preconditioner CG convergence theory still applies to, not just a + /// faster-but-wrong shortcut -- and, on a representative multi-family + /// problem (value + gradient + norm + curvature + border, a few + /// thousand dof), do it in fewer iterations. + #[test] + fn block_jacobi_converges_to_same_solution_in_fewer_iterations() { + let grid = StructuredGrid3D::new([0.0, 0.0, 0.0], [22, 18, 16], [1.0, 1.0, 1.0]); + let mut interp = FiniteDifferenceInterpolator::new(grid) + .with_regularisation(RegularisationWeights::default()) + .with_border_regularisation(BorderRegularisationWeights::default()); + + let f = + |p: [f64; 3]| ((p[0] * 0.4).sin() * (p[1] * 0.3).cos() * 3.0) + p[2] * 0.2 - 0.1 * p[0]; + for p in [ + [2.0, 2.0, 2.0], + [18.0, 4.0, 6.0], + [5.0, 14.0, 3.0], + [10.0, 9.0, 12.0], + [3.0, 3.0, 13.0], + [15.0, 15.0, 8.0], + ] { + interp.add_value_constraint(p, f(p), 20.0); + } + interp.add_gradient_constraint([8.0, 8.0, 8.0], [1.0, 0.2, -0.3], 5.0); + interp.add_norm_constraint([12.0, 6.0, 9.0], [0.1, 0.2, 0.05], 3.0); + + let jacobi = interp.solve_with(5000, 1e-10, PreconditionerKind::Jacobi); + let block = interp.solve_with(5000, 1e-10, PreconditionerKind::BlockJacobiX); + + assert!(jacobi.converged, "jacobi iterations={}", jacobi.iterations); + assert!( + block.converged, + "block-jacobi iterations={}", + block.iterations + ); + + let max_diff = jacobi + .x + .iter() + .zip(block.x.iter()) + .map(|(a, b)| (a - b).abs()) + .fold(0.0, f64::max); + assert!(max_diff < 1e-5, "solutions diverged: max_diff={max_diff}"); + + assert!( + block.iterations <= jacobi.iterations, + "expected block-Jacobi to need no more iterations than plain Jacobi: jacobi={} block={}", + jacobi.iterations, + block.iterations + ); + eprintln!( + "block_jacobi_converges_to_same_solution_in_fewer_iterations: jacobi={} iters, block_jacobi_x={} iters (dof={})", + jacobi.iterations, + block.iterations, + interp.dof() + ); + } + + /// Degenerate case: no `dxx` weight and no x-axis border regularisation + /// means `dxx_and_border_dx_offdiagonal` is all zeros, so `BlockJacobiX` + /// degenerates to exactly plain Jacobi (a tridiagonal solve with zero + /// off-diagonal is just `z[i] = r[i] / diag[i]` per entry) -- confirms + /// it never does *worse* than Jacobi even when there's no coupling to + /// exploit, and that the degenerate Thomas-algorithm path doesn't panic + /// or diverge. + #[test] + fn block_jacobi_matches_jacobi_when_no_x_coupling_present() { + let grid = StructuredGrid3D::new([0.0, 0.0, 0.0], [10, 8, 7], [1.0, 1.0, 1.0]); + let mut interp = FiniteDifferenceInterpolator::new(grid) + .with_regularisation(RegularisationWeights { + dxx: 0.0, + dyy: 1.0, + dzz: 1.0, + dxy: 0.0, + dxz: 0.0, + dyz: 0.0, + }) + .with_border_regularisation(BorderRegularisationWeights { + dx: 0.0, + dy: 1.0, + dz: 1.0, + }); + interp.add_value_constraint([1.0, 1.0, 1.0], 1.0, 10.0); + interp.add_value_constraint([8.0, 6.0, 5.0], -1.0, 10.0); + + let jacobi = interp.solve_with(2000, 1e-11, PreconditionerKind::Jacobi); + let block = interp.solve_with(2000, 1e-11, PreconditionerKind::BlockJacobiX); + assert!(jacobi.converged && block.converged); + assert_eq!(jacobi.iterations, block.iterations); + let max_diff = jacobi + .x + .iter() + .zip(block.x.iter()) + .map(|(a, b)| (a - b).abs()) + .fold(0.0, f64::max); + assert!(max_diff < 1e-9, "max_diff={max_diff}"); + } + + // ---- Manual before/after profiling (not run by default) ---- + + /// Before/after benchmark for the fused single-kernel regularisation + /// optimisation (TASKS.md P1). Run with: + /// `cargo test --release -- --ignored --nocapture profile_apply_1e6`. + #[test] + #[ignore] + fn profile_apply_1e6() { + let grid = StructuredGrid3D::new([0.0, 0.0, 0.0], [100, 100, 100], [1.0, 1.0, 1.0]); + let mut interp = FiniteDifferenceInterpolator::new(grid); + for p in [[10.0, 10.0, 10.0], [50.0, 50.0, 50.0], [90.0, 5.0, 80.0]] { + interp.add_value_constraint(p, 1.0, 10.0); + } + let n = interp.dof(); + let x: Vec = (0..n) + .map(|i| ((i * 31 + 3) % 101) as f64 / 101.0) + .collect(); + let mut scratch = vec![0.0; interp.scratch_len()]; + let mut out = vec![0.0; n]; + for _ in 0..3 { + interp.apply_normal_operator_into(&x, &mut scratch, &mut out); + } + let iters = 20; + let start = std::time::Instant::now(); + for _ in 0..iters { + interp.apply_normal_operator_into(&x, &mut scratch, &mut out); + } + let elapsed = start.elapsed(); + eprintln!( + "profile_apply_1e6 [DEFAULT, class stencil]: {:?}/call over {iters} calls (dof={n})", + elapsed / iters + ); + } + + /// As `profile_apply_1e6`, but using the reference (pre-fusion) two-pass + /// computation -- the "before" measurement for the same optimisation. + #[test] + #[ignore] + fn profile_apply_1e6_unfused() { + let grid = StructuredGrid3D::new([0.0, 0.0, 0.0], [100, 100, 100], [1.0, 1.0, 1.0]); + let mut interp = FiniteDifferenceInterpolator::new(grid); + for p in [[10.0, 10.0, 10.0], [50.0, 50.0, 50.0], [90.0, 5.0, 80.0]] { + interp.add_value_constraint(p, 1.0, 10.0); + } + let n = interp.dof(); + let x: Vec = (0..n) + .map(|i| ((i * 31 + 3) % 101) as f64 / 101.0) + .collect(); + let mut scratch = vec![0.0; interp.scratch_len_unfused()]; + let mut out = vec![0.0; n]; + for _ in 0..3 { + interp.apply_normal_operator_into_unfused(&x, &mut scratch, &mut out); + } + let iters = 20; + let start = std::time::Instant::now(); + for _ in 0..iters { + interp.apply_normal_operator_into_unfused(&x, &mut scratch, &mut out); + } + let elapsed = start.elapsed(); + eprintln!( + "profile_apply_1e6_unfused [UNFUSED]: {:?}/call over {iters} calls (dof={n})", + elapsed / iters + ); + } + + /// `profile_apply_1e6`/`profile_apply_1e6_unfused`'s overall + /// `apply_normal_operator_into` speedup is small: border regularisation + /// (six centred `[1, -2, 1]` `EdgeStencil` families, each still using the original, + /// unchanged, full-`dof`-iterating exact two-pass computation -- out of + /// scope for this optimisation, see the module docs) contributes far + /// more O(dof) passes (12: forward+adjoint x 6 border families) than the + /// six interior curvature families ever did (2: one forward, one + /// adjoint, now collapsed to ~1), so it dominates the *total* time and + /// dilutes the interior fusion's own speedup in that measurement. This + /// variant disables border regularisation to isolate and measure the + /// interior-family fusion in isolation -- the actual thing this + /// optimisation targets. + #[test] + #[ignore] + fn profile_apply_1e6_interior_only() { + let grid = StructuredGrid3D::new([0.0, 0.0, 0.0], [100, 100, 100], [1.0, 1.0, 1.0]); + let mut interp = FiniteDifferenceInterpolator::new(grid).with_border_regularisation( + BorderRegularisationWeights { + dx: 0.0, + dy: 0.0, + dz: 0.0, + }, + ); + for p in [[10.0, 10.0, 10.0], [50.0, 50.0, 50.0], [90.0, 5.0, 80.0]] { + interp.add_value_constraint(p, 1.0, 10.0); + } + let n = interp.dof(); + let x: Vec = (0..n) + .map(|i| ((i * 31 + 3) % 101) as f64 / 101.0) + .collect(); + let mut scratch = vec![0.0; interp.scratch_len()]; + let mut out = vec![0.0; n]; + for _ in 0..3 { + interp.apply_normal_operator_into(&x, &mut scratch, &mut out); + } + let iters = 20; + let start = std::time::Instant::now(); + for _ in 0..iters { + interp.apply_normal_operator_into(&x, &mut scratch, &mut out); + } + let elapsed = start.elapsed(); + eprintln!( + "profile_apply_1e6_interior_only [DEFAULT, class stencil, no border]: {:?}/call over {iters} calls (dof={n})", + elapsed / iters + ); + } + + #[test] + #[ignore] + fn profile_apply_1e6_interior_only_unfused() { + let grid = StructuredGrid3D::new([0.0, 0.0, 0.0], [100, 100, 100], [1.0, 1.0, 1.0]); + let mut interp = FiniteDifferenceInterpolator::new(grid).with_border_regularisation( + BorderRegularisationWeights { + dx: 0.0, + dy: 0.0, + dz: 0.0, + }, + ); + for p in [[10.0, 10.0, 10.0], [50.0, 50.0, 50.0], [90.0, 5.0, 80.0]] { + interp.add_value_constraint(p, 1.0, 10.0); + } + let n = interp.dof(); + let x: Vec = (0..n) + .map(|i| ((i * 31 + 3) % 101) as f64 / 101.0) + .collect(); + let mut scratch = vec![0.0; interp.scratch_len_unfused()]; + let mut out = vec![0.0; n]; + for _ in 0..3 { + interp.apply_normal_operator_into_unfused(&x, &mut scratch, &mut out); + } + let iters = 20; + let start = std::time::Instant::now(); + for _ in 0..iters { + interp.apply_normal_operator_into_unfused(&x, &mut scratch, &mut out); + } + let elapsed = start.elapsed(); + eprintln!( + "profile_apply_1e6_interior_only_unfused [UNFUSED, no border]: {:?}/call over {iters} calls (dof={n})", + elapsed / iters + ); + } + + /// An axis-aligned direction field (`(0,0,1)` everywhere) collapses + /// `comp_w` to `[0,0,1,0,0,0]`, so `add_directional_regularisation` + /// should contribute *exactly* the same normal operator as the existing + /// isotropic `dzz` family scaled by the same weight -- the strongest + /// correctness check available (an exact numeric match, not just + /// "plausible"), and confirms the six families stay independent (no + /// cross-family terms leak in for a pure axis direction). + #[test] + fn directional_regularisation_matches_isotropic_family_for_axis_aligned_direction() { + let w = 0.7; + let grid = make_grid(); + let n = grid.n_nodes(); + + let mut isotropic = FiniteDifferenceInterpolator::new(grid) + .with_regularisation(RegularisationWeights { + dxx: 0.0, + dyy: 0.0, + dzz: w, + dxy: 0.0, + dxz: 0.0, + dyz: 0.0, + }) + .with_border_regularisation(BorderRegularisationWeights { + dx: 0.0, + dy: 0.0, + dz: 0.0, + }); + isotropic.add_value_constraint([2.0, 2.0, 2.0], 1.0, 1.0); + + let mut directional = FiniteDifferenceInterpolator::new(grid) + .with_regularisation(RegularisationWeights { + dxx: 0.0, + dyy: 0.0, + dzz: 0.0, + dxy: 0.0, + dxz: 0.0, + dyz: 0.0, + }) + .with_border_regularisation(BorderRegularisationWeights { + dx: 0.0, + dy: 0.0, + dz: 0.0, + }); + directional.add_value_constraint([2.0, 2.0, 2.0], 1.0, 1.0); + let directions = vec![[0.0, 0.0, 1.0]; n]; + directional.add_directional_regularisation(&directions, w); + + let x: Vec = (0..n) + .map(|i| ((i * 31 + 3) % 101) as f64 / 101.0) + .collect(); + let iso_out = isotropic.apply_normal_operator(&x); + let dir_out = directional.apply_normal_operator(&x); + for (a, b) in iso_out.iter().zip(dir_out.iter()) { + assert!((a - b).abs() < 1e-10, "isotropic={a} directional={b}"); + } + } + + /// Symmetry check with a directional-regularisation term active + /// (mirrors `normal_operator_is_symmetric_with_all_constraint_families`), + /// guarding against the new unfused per-node-weighted pass breaking `N`'s + /// self-adjointness. + #[test] + fn normal_operator_is_symmetric_with_directional_regularisation() { + let grid = make_grid(); + let n = grid.n_nodes(); + let mut interp = FiniteDifferenceInterpolator::new(grid); + interp.add_value_constraint([2.0, 2.0, 2.0], 1.0, 1.0); + // A non-axis-aligned, per-node-varying direction field so every + // family (including the cross-derivative ones) picks up a nonzero, + // spatially-varying weight. + let directions: Vec<[f64; 3]> = (0..n) + .map(|i| { + let t = i as f64 * 0.37; + let v = [t.sin(), (t * 1.3).cos(), (t * 0.7).sin()]; + let norm = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt(); + if norm > 0.0 { + [v[0] / norm, v[1] / norm, v[2] / norm] + } else { + [1.0, 0.0, 0.0] + } + }) + .collect(); + interp.add_directional_regularisation(&directions, 0.3); + + let x: Vec = (0..n) + .map(|i| ((i * 31 + 3) % 101) as f64 / 101.0) + .collect(); + let y: Vec = (0..n).map(|i| ((i * 17 + 5) % 89) as f64 / 89.0).collect(); + let nx = interp.apply_normal_operator(&x); + let ny = interp.apply_normal_operator(&y); + let lhs: f64 = nx.iter().zip(y.iter()).map(|(a, b)| a * b).sum(); + let rhs: f64 = x.iter().zip(ny.iter()).map(|(a, b)| a * b).sum(); + assert!( + (lhs - rhs).abs() < 1e-6 * lhs.abs().max(1.0), + "lhs={lhs} rhs={rhs}" + ); + } + + /// Two calls with weight `w/sqrt(2)` (same direction) must accumulate to + /// the same squared-weight total as one call with weight `w` -- + /// confirms `add_directional_regularisation` sums independent terms + /// rather than overwriting the running per-node weight state. + #[test] + fn directional_regularisation_accumulates_across_calls() { + let w = 0.9; + let grid = make_grid(); + let n = grid.n_nodes(); + let directions = vec![[0.6, 0.8, 0.0]; n]; // already unit-length + + let mut once = FiniteDifferenceInterpolator::new(grid); + once.add_value_constraint([2.0, 2.0, 2.0], 1.0, 1.0); + once.add_directional_regularisation(&directions, w); + + let mut twice = FiniteDifferenceInterpolator::new(grid); + twice.add_value_constraint([2.0, 2.0, 2.0], 1.0, 1.0); + let half = w / std::f64::consts::SQRT_2; + twice.add_directional_regularisation(&directions, half); + twice.add_directional_regularisation(&directions, half); + + let x: Vec = (0..n) + .map(|i| ((i * 31 + 3) % 101) as f64 / 101.0) + .collect(); + let once_out = once.apply_normal_operator(&x); + let twice_out = twice.apply_normal_operator(&x); + for (a, b) in once_out.iter().zip(twice_out.iter()) { + assert!((a - b).abs() < 1e-9, "once={a} twice={b}"); + } + } + + /// Guards against a regression that would make every non-fold caller of + /// this crate pay for directional regularisation it never asked for: + /// with no `add_directional_regularisation` calls, `directional_weight2` + /// stays `None`, and `scratch_len`/`apply_normal_operator`/`diagonal` + /// must be byte-for-byte identical to before this feature existed. + #[test] + fn directional_regularisation_is_zero_cost_when_unused() { + let grid = make_grid(); + let n = grid.n_nodes(); + let mut interp = FiniteDifferenceInterpolator::new(grid); + interp.add_value_constraint([2.0, 2.0, 2.0], 1.0, 1.0); + interp.add_gradient_constraint([3.0, 3.0, 3.0], [1.0, 0.0, 0.0], 2.0); + + assert!(interp.directional_weight2.is_none()); + + let x: Vec = (0..n) + .map(|i| ((i * 31 + 3) % 101) as f64 / 101.0) + .collect(); + let out = interp.apply_normal_operator(&x); + let diag = interp.diagonal(); + // Sanity: still a well-formed, finite result (the real regression + // guard here is `directional_weight2.is_none()` above -- this just + // confirms the untouched code paths still produce sane output). + assert!(out.iter().all(|v| v.is_finite())); + assert!(diag.iter().all(|v| v.is_finite() && *v > 0.0)); + } +} diff --git a/packages/loop_interpolation_core/src/lib.rs b/packages/loop_interpolation_core/src/lib.rs new file mode 100644 index 00000000..6d6bb9cc --- /dev/null +++ b/packages/loop_interpolation_core/src/lib.rs @@ -0,0 +1,66 @@ +//! Core crate for the low-memory interpolator. +//! +//! The crate has a matrix-free finite-difference interpolator. It does not +//! build a sparse matrix. It applies the constraints as linear operators and +//! solves with a conjugate gradient method. + +pub mod admm; +pub mod cg; +pub(crate) mod class_stencil; +pub mod error; +pub mod field; +pub mod grid; +pub mod interpolator; +pub mod memory; +pub(crate) mod par; +pub mod problem; +pub mod report; +pub mod scalar; +pub mod session; +pub mod settings; +pub mod stencil; + +#[cfg(feature = "python")] +mod python; +#[cfg(feature = "wasm")] +mod wasm; + +/// Rayon chunk-splitting floor shared by every parallel pass in the CG hot +/// loop (`cg.rs`, `interpolator.rs`, `stencil.rs`). Rayon's default +/// splitting for a plain `.par_iter()` recursively halves a slice down to +/// very small chunks (adaptively work-stolen), which -- confirmed by +/// benchmarking the real-world `examples/claudius_fdi_comparison.py` +/// dataset (dof in the tens of thousands, well below the `dof=1_000_000` +/// grid `interpolator.rs`'s `profile_apply_1e6` tests use to validate this +/// crate's performance) -- creates *more* tiny tasks than the ~20 hardware +/// threads can usefully absorb, so thread-pool dispatch/work-stealing +/// overhead (paid on a dozen-plus separate `.par_iter()` calls per CG +/// iteration) dominates over the arithmetic. Forcing complete +/// sequential execution below some dof threshold turned out to be the wrong +/// fix, though: at dof ~45,000 it was *worse* than plain rayon defaults +/// (measured ~7.8s vs. ~3.3s), since that grid has enough real per-node work +/// to profit from spreading across cores -- it just needs coarser, not +/// finer, chunks. `.with_min_len(PAR_MIN_LEN)` caps how many chunks rayon is +/// allowed to split a call into (roughly `len / PAR_MIN_LEN` at most), +/// trading fewer, larger chunks (less dispatch overhead, still parallel) +/// for the very fine adaptive splitting rayon defaults to. 200 was chosen by +/// sweeping candidates against the claudius benchmark at both dof~6,000 and +/// dof~45,000 and picking the value that won at both scales (~3x faster at +/// the small end, ~1.8x at the large end, vs. no `with_min_len` at all) -- +/// re-sweep (`examples/claudius_fdi_comparison.py`, plus +/// `profile_apply_1e6`/`profile_apply_1e6_unfused` for the large-grid end) +/// if the target hardware's core count changes significantly. +pub(crate) const PAR_MIN_LEN: usize = 200; + +pub use cg::CgResult; +pub use error::{BoundKind, CoreError, ViolatedConstraint}; +pub use field::{Field, FieldData}; +pub use grid::StructuredGrid3D; +pub use interpolator::{ + AdmmResult, BorderRegularisationWeights, FiniteDifferenceInterpolator, RegularisationWeights, +}; +pub use memory::{MemTracker, ProblemCounts, estimate, max_grid_for_budget}; +pub use problem::Problem; +pub use report::{MemoryEstimate, Progress, SolveReport, Stage, Step}; +pub use session::SolveSession; +pub use settings::{Precision, Settings}; diff --git a/packages/loop_interpolation_core/src/memory.rs b/packages/loop_interpolation_core/src/memory.rs new file mode 100644 index 00000000..f3e76d8a --- /dev/null +++ b/packages/loop_interpolation_core/src/memory.rs @@ -0,0 +1,343 @@ +//! Memory tracking and memory estimate. + +use crate::error::CoreError; +use crate::report::MemoryEstimate; +use crate::settings::{Precision, Settings}; + +/// Counts allocated bytes, keeps the peak, and refuses to go over the budget. +#[derive(Debug, Clone)] +pub struct MemTracker { + current: u64, + peak: u64, + #[allow(dead_code)] + budget: Option, +} + +impl MemTracker { + pub fn new(budget: Option) -> Self { + Self { + current: 0, + peak: 0, + budget, + } + } + + /// Allocate a vector of `n` items, each set to `T::default()`. + /// + /// The byte count of the request is checked against the budget first. + /// Nothing is allocated when the request is refused. + pub fn try_zeroed(&mut self, n: usize) -> Result, CoreError> { + // Compute the request in `u64`. A product that does not fit is out of memory. + let item_bytes = std::mem::size_of::() as u64; + let request = (n as u64) + .checked_mul(item_bytes) + .ok_or(CoreError::OutOfMemory { + requested_bytes: u64::MAX, + })?; + + // Check the budget before any allocation. + if let Some(budget) = self.budget { + let estimate = self.current.saturating_add(request); + if estimate > budget { + return Err(CoreError::OverBudget { + estimate_bytes: estimate, + budget_bytes: budget, + // The caller fills in the real grid size later. + max_nodes_per_axis: 0, + }); + } + } + + // Reserve the memory. A failure is returned as an error, not an abort. + let mut values: Vec = Vec::new(); + values + .try_reserve_exact(n) + .map_err(|_| CoreError::OutOfMemory { + requested_bytes: request, + })?; + values.resize(n, T::default()); + + self.current = self.current.saturating_add(request); + self.peak = self.peak.max(self.current); + Ok(values) + } + + /// Give back `bytes` to the budget. + pub fn release(&mut self, bytes: u64) { + self.current = self.current.saturating_sub(bytes); + } + + pub fn current(&self) -> u64 { + self.current + } + + pub fn peak(&self) -> u64 { + self.peak + } +} + +/// Counts of a problem. The estimate uses these. +#[derive(Debug, Clone, Default)] +pub struct ProblemCounts { + /// Number of unknowns (nodes of the grid). + pub dof: u64, + pub n_value: u64, + pub n_gradient: u64, + pub n_normal: u64, + pub n_tangent: u64, + /// Interface points. + pub n_interface: u64, + /// Rows of pair bounds `lower <= f(a) - f(b) <= upper`. + pub n_pair_bound: u64, + /// Rows of value bounds. + pub n_value_bound: u64, + /// True when directional regularisation is on. + pub uses_directional: bool, + /// True when the problem has a start value (8 bytes for each dof). + pub has_start: bool, + /// True when the operator uses the per-class stencil: every axis has at + /// least 7 nodes and there is no directional regularisation. + pub class_stencil: bool, +} + +// Memory model. All numbers come from the counting allocator, for grids +// from 10^3 to 200^3 (500 value rows and 125 gradient rows, 20 iterations; +// also 50+10 and 2000+500 rows). The peak fits a model with 3 terms: +// +// peak = 52.6 * dof + (1020 * n^2 - 4400 * n) + 190 * rows +// +// where `n` is the number of nodes per axis, `dof = n^3`, and a gradient +// counts as 2 stored rows. The estimate uses 53 bytes for each dof and adds +// a margin of 6 percent at the end. For the CI sizes the estimate is 1.05 to +// 1.12 times the peak, for the sizes up to 200^3 it is 1.05 to 1.07 times. +// +// Per unknown (`dof`), with `f64` vectors: +// +// | Part | Item | Bytes per dof | +// |-------------|----------------------------------------|---------------| +// | vectors | x, r, p, ap, inv_diag (5 x 8) | 40 | +// | working | copy of the field for the caller (f64) | 8 | +// | working | shell lookup (u32) | 4 | +// | working | rest (tables, vector capacity) | 1 | +// | working | directional weights (only if used) | 48 | +// +// Plain CG: 40 + 8 + 4 + 1 = 53 bytes per dof. With `f32` the measured peak +// is 32 bytes per dof lower: the vectors are 20 bytes, and the field copy +// and the shell lookup are not kept (working: 1 byte per dof). +// +// Per surface node (`n^2`): the shell and border scratch grow with the +// border of the grid, not with its volume: 1020 bytes for each `n^2`. For +// small grids the border is smaller than this term: the estimate takes off +// 4400 bytes for each `n`. +// +// Fixed: conflict-check table, small tables and allocator slack. +// +// Per-class stencil (the default when every axis has 7 or more nodes and +// there is no directional regularisation): there is no shell lookup, no +// scratch buffer and no surface term. Measured for 30^3, 45^3 and 60^3 with +// `f64`, the peak grows by exactly the 40 vector bytes for each dof. The +// final residual check of `f32` needs 16 bytes for each dof, which is less +// than its 20 vector bytes. The estimate keeps 1 working byte for each dof +// and adds the kernel table (`class_stencil::MAX_BYTES`) to the fixed part. +const VECTOR_BYTES_PER_DOF: u64 = 40; +/// Vector bytes for each dof with `f32`. +const VECTOR_BYTES_PER_DOF_F32: u64 = 20; +const WORKING_BYTES_PER_DOF: u64 = 8 + 4 + 1; +/// Working bytes for each dof with `f32`. +const WORKING_BYTES_PER_DOF_F32: u64 = 1; +/// Working bytes for each dof with the per-class stencil (`f64` and `f32`). +const CLASS_WORKING_BYTES_PER_DOF: u64 = 1; +const ADMM_BYTES_PER_DOF: u64 = 0; +const DIRECTIONAL_BYTES_PER_DOF: u64 = 48; +/// Start value of the solve (f64), only if it is set. +const START_BYTES_PER_DOF: u64 = 8; +/// Bytes for each `n^2` (shell and border scratch). +const SURFACE_BYTES_PER_NODE: u64 = 1020; +/// Bytes taken off for each `n` (the border of a small grid). +const EDGE_BYTES_PER_NODE: u64 = 4400; +/// Fixed bytes: tables, allocator slack. +const FIXED_BYTES: u64 = 16 * 1024; +/// Margin on the sum, in percent. +const MARGIN_PERCENT: u128 = 106; + +// Bytes per constraint record, as measured: 190 bytes for each stored row. +// +// | Family | Bytes per row | +// |---------------|---------------| +// | value | 190 | +// | gradient | 190 | +// | normal | 190 | +// | tangent | 190 | +// | interface | 380 | +// | pair bound | 400 | +// | value bound | 210 | +// +// A stored row of the interpolator holds 8 `usize` indices, 8 `f64` +// coefficients, a value and a weight (144 bytes). The rest (about 46 bytes) is +// the capacity slack of the vectors. A gradient makes 2 stored rows. A pair row holds two such parts. +// The counts are stored rows. A normal makes 3 rows. +const VALUE_RECORD: u64 = 190; +const VECTOR_RECORD: u64 = 190; +const INTERFACE_RECORD: u64 = 380; +const PAIR_BOUND_RECORD: u64 = 400; +const VALUE_BOUND_RECORD: u64 = 210; +/// ADMM keeps 3 `f64` values for each bound row (z, dual, work). +const ADMM_BYTES_PER_BOUND_ROW: u64 = 24; + +/// Estimate the memory of a solve. It allocates nothing. +pub fn estimate(c: &ProblemCounts, s: &Settings) -> MemoryEstimate { + let dof = c.dof; + + // Vectors. `f32` halves the vector part. + let f32 = s.precision == Precision::F32; + let vectors = dof.saturating_mul(if f32 { + VECTOR_BYTES_PER_DOF_F32 + } else { + VECTOR_BYTES_PER_DOF + }); + + // Constraint records. + let vector_rows = c + .n_gradient + .saturating_add(c.n_normal) + .saturating_add(c.n_tangent); + let constraints = c + .n_value + .saturating_mul(VALUE_RECORD) + .saturating_add(vector_rows.saturating_mul(VECTOR_RECORD)) + .saturating_add(c.n_interface.saturating_mul(INTERFACE_RECORD)) + .saturating_add(c.n_pair_bound.saturating_mul(PAIR_BOUND_RECORD)) + .saturating_add(c.n_value_bound.saturating_mul(VALUE_BOUND_RECORD)); + + // Working memory: tables, scratch, ADMM extra, directional weights. + let mut per_dof = if c.class_stencil { + CLASS_WORKING_BYTES_PER_DOF + } else if f32 { + WORKING_BYTES_PER_DOF_F32 + } else { + WORKING_BYTES_PER_DOF + }; + let bound_rows = c.n_pair_bound.saturating_add(c.n_value_bound); + let mut working = 0u64; + if bound_rows > 0 { + per_dof += ADMM_BYTES_PER_DOF; + working = bound_rows.saturating_mul(ADMM_BYTES_PER_BOUND_ROW); + } + if c.uses_directional { + per_dof += DIRECTIONAL_BYTES_PER_DOF; + } + if c.has_start { + // The f64 start value stays until the solver vectors are made. + per_dof += START_BYTES_PER_DOF; + } + working = working.saturating_add(dof.saturating_mul(per_dof)); + if c.class_stencil { + working = working + .saturating_add(crate::class_stencil::MAX_BYTES) + .saturating_add(FIXED_BYTES); + } else { + let axis = (dof as f64).cbrt(); + let surface = (axis * axis).round() as u64; + let edge = axis.round() as u64; + working = working + .saturating_add(surface.saturating_mul(SURFACE_BYTES_PER_NODE)) + .saturating_sub(edge.saturating_mul(EDGE_BYTES_PER_NODE)) + .saturating_add(FIXED_BYTES); + } + + // Total with the margin. Use `u128` so that it cannot overflow. + let sum = vectors as u128 + constraints as u128 + working as u128; + let total = (sum * MARGIN_PERCENT / 100).min(u64::MAX as u128) as u64; + + MemoryEstimate { + vectors, + constraints, + working, + total, + } +} + +/// Largest number of nodes for each axis that fits in `budget`. +/// The result is at least 1. The function allocates nothing. +pub fn max_grid_for_budget(c: &ProblemCounts, s: &Settings, budget: u64) -> usize { + // 2 000 000 ^ 3 = 8e18, this fits in `u64`. + const MAX_AXIS: u64 = 2_000_000; + let fits = |n: u64| { + let mut counts = c.clone(); + counts.dof = n * n * n; + estimate(&counts, s).total <= budget + }; + // Invariant: `lo` fits (or is 1), `hi` is the upper limit. + let mut lo = 1u64; + let mut hi = MAX_AXIS; + while lo < hi { + let mid = lo + (hi - lo).div_ceil(2); + if fits(mid) { + lo = mid; + } else { + hi = mid - 1; + } + } + lo as usize +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::error::CoreError; + + /// Size of one `f64` in bytes. + const F64_BYTES: u64 = 8; + + // A request for 10 `f64` values counts 80 bytes. + // The peak becomes 80 too. + #[test] + fn allocation_counts_bytes_and_peak() { + let mut tracker = MemTracker::new(None); + let v: Vec = tracker + .try_zeroed(10) + .expect("allocation of 10 f64 must succeed"); + assert_eq!(v.len(), 10); + assert_eq!(tracker.current(), 10 * F64_BYTES); + assert_eq!(tracker.peak(), 10 * F64_BYTES); + } + + // Releasing the 80 bytes sets the current count to 0. + // The peak stays at 80. + #[test] + fn release_lowers_current_and_keeps_peak() { + let mut tracker = MemTracker::new(None); + let _v: Vec = tracker + .try_zeroed(10) + .expect("allocation of 10 f64 must succeed"); + tracker.release(10 * F64_BYTES); + assert_eq!(tracker.current(), 0); + assert_eq!(tracker.peak(), 10 * F64_BYTES); + } + + // The budget is 79 bytes. A request for 80 bytes is refused. + // Nothing is counted, so current and peak stay at 0. + #[test] + fn over_budget_request_is_refused_before_allocation() { + let mut tracker = MemTracker::new(Some(79)); + let result = tracker.try_zeroed::(10); + assert!( + matches!(result, Err(CoreError::OverBudget { .. })), + "expected OverBudget error" + ); + assert_eq!(tracker.current(), 0); + assert_eq!(tracker.peak(), 0); + } + + // A request that is far too large returns an error. + // It must not abort the process. + #[test] + fn huge_request_gives_out_of_memory_not_abort() { + let mut tracker = MemTracker::new(None); + let result = tracker.try_zeroed::(usize::MAX / 16); + assert!( + matches!(result, Err(CoreError::OutOfMemory { .. })), + "expected OutOfMemory error" + ); + } +} diff --git a/packages/loop_interpolation_core/src/par.rs b/packages/loop_interpolation_core/src/par.rs new file mode 100644 index 00000000..936d9cae --- /dev/null +++ b/packages/loop_interpolation_core/src/par.rs @@ -0,0 +1,65 @@ +//! Optional parallel layer. +//! +//! With feature `parallel`, this module gives the `rayon` prelude. +//! Without the feature (for example on `wasm32-unknown-unknown`, which has +//! no threads), it gives traits with the same method names on std +//! iterators and slices. Each method runs on one thread and gives the same +//! result as the `rayon` method. +//! +//! The shim has only the methods that this crate uses: `par_iter`, +//! `par_iter_mut`, `par_chunks_mut`, `into_par_iter` and `with_min_len`. +//! All other iterator methods (`zip`, `enumerate`, `map`, `sum`, +//! `collect`, `for_each`) come from `std::iter::Iterator`. + +#[cfg(feature = "parallel")] +pub(crate) use rayon::prelude::*; + +#[cfg(not(feature = "parallel"))] +pub(crate) use shim::*; + +#[cfg(not(feature = "parallel"))] +mod shim { + /// Slice methods with the `rayon` names. + pub(crate) trait ParSliceShim { + fn par_iter(&self) -> std::slice::Iter<'_, T>; + fn par_iter_mut(&mut self) -> std::slice::IterMut<'_, T>; + fn par_chunks_mut(&mut self, chunk_size: usize) -> std::slice::ChunksMut<'_, T>; + } + + impl ParSliceShim for [T] { + #[inline] + fn par_iter(&self) -> std::slice::Iter<'_, T> { + self.iter() + } + + #[inline] + fn par_iter_mut(&mut self) -> std::slice::IterMut<'_, T> { + self.iter_mut() + } + + #[inline] + fn par_chunks_mut(&mut self, chunk_size: usize) -> std::slice::ChunksMut<'_, T> { + self.chunks_mut(chunk_size) + } + } + + /// `into_par_iter` for any value that has `into_iter` (for example a range). + pub(crate) trait IntoParIterShim: IntoIterator + Sized { + #[inline] + fn into_par_iter(self) -> Self::IntoIter { + self.into_iter() + } + } + + impl IntoParIterShim for I {} + + /// `with_min_len` does nothing with one thread. It returns `self`. + pub(crate) trait WithMinLenShim: Iterator + Sized { + #[inline] + fn with_min_len(self, _min: usize) -> Self { + self + } + } + + impl WithMinLenShim for I {} +} diff --git a/packages/loop_interpolation_core/src/problem.rs b/packages/loop_interpolation_core/src/problem.rs new file mode 100644 index 00000000..363053d7 --- /dev/null +++ b/packages/loop_interpolation_core/src/problem.rs @@ -0,0 +1,590 @@ +//! The problem: grid, constraints and weights. + +use std::collections::HashMap; +use std::fmt; + +use crate::error::CoreError; +use crate::field::Field; +use crate::grid::StructuredGrid3D; +use crate::interpolator::{ + BorderRegularisationWeights, FiniteDifferenceInterpolator, RegularisationWeights, +}; +use crate::memory::{ProblemCounts, estimate, max_grid_for_budget}; +use crate::report::{MemoryEstimate, SolveReport, Step}; +use crate::session::SolveSession; +use crate::settings::Settings; + +/// The most nodes that a grid can have: `isize::MAX / 8` (FR-018). +const MAX_NODES: u64 = isize::MAX as u64 / 8; + +/// From this many value rows the conflict check is skipped. +const CONFLICT_CHECK_LIMIT: usize = 1_000_000; + +/// Make the error for a bad row. `family` is a name such as "value". +fn bad_row(family: &str, row: usize, reason: &str) -> CoreError { + CoreError::InvalidInput { + constraint: format!("{family}[{row}]"), + reason: reason.into(), + } +} + +/// Make the error for a bad array or a bad scalar. No row is named. +fn bad_input(family: &str, reason: &str) -> CoreError { + CoreError::InvalidInput { + constraint: family.into(), + reason: reason.into(), + } +} + +/// Check that a flat array holds 3 values for each point. Return the count. +fn count_points(family: &str, name: &str, flat: &[f64]) -> Result { + if !flat.len().is_multiple_of(3) { + return Err(bad_input( + family, + &format!("{name} length {} is not a multiple of 3", flat.len()), + )); + } + Ok(flat.len() / 3) +} + +/// Check that an array has `n` values. +fn check_len(family: &str, name: &str, len: usize, n: usize) -> Result<(), CoreError> { + if len != n { + return Err(bad_input( + family, + &format!("{name} has {len} values; {n} are needed"), + )); + } + Ok(()) +} + +/// Get row `i` of a flat array of 3-vectors. +fn row3(flat: &[f64], i: usize) -> [f64; 3] { + [flat[3 * i], flat[3 * i + 1], flat[3 * i + 2]] +} + +/// Check that all 3 values of row `i` are finite. +fn check_finite3(family: &str, flat: &[f64], i: usize, what: &str) -> Result<(), CoreError> { + if row3(flat, i).iter().all(|v| v.is_finite()) { + Ok(()) + } else { + Err(bad_row(family, i, &format!("{what} is not finite"))) + } +} + +/// Check that a weight is finite and more than zero. +fn check_weight(family: &str, row: usize, w: f64) -> Result<(), CoreError> { + if w.is_finite() && w > 0.0 { + Ok(()) + } else { + Err(bad_row( + family, + row, + "weight must be finite and more than 0", + )) + } +} + +/// Check that a vector row is finite and not zero. +fn check_vector(family: &str, flat: &[f64], i: usize) -> Result<(), CoreError> { + check_finite3(family, flat, i, "vector")?; + let v = row3(flat, i); + if v[0] * v[0] + v[1] * v[1] + v[2] * v[2] > 0.0 { + Ok(()) + } else { + Err(bad_row(family, i, "vector has zero length")) + } +} + +/// Check a pair of bounds. `lower` and `upper` can be infinite. +fn check_bounds(family: &str, row: usize, lower: f64, upper: f64) -> Result<(), CoreError> { + if lower.is_nan() || upper.is_nan() { + return Err(bad_row(family, row, "bound is NaN")); + } + if lower > upper { + return Err(bad_row(family, row, "lower bound is more than upper bound")); + } + Ok(()) +} + +/// A flat-input interpolation problem. Arrays are flat: points have 3n values. +/// +/// The problem holds the ported interpolator. The interpolator is not +/// `Clone`, so the problem is not `Clone`. +pub struct Problem { + /// Always `Some`, except for a short time inside `with_interp`. + interp: Option, + pub(crate) warnings: Vec, + /// First value seen at each point (bits of the coordinates). + /// `None` after the conflict check is skipped. + seen_values: Option>, + /// Number of value rows seen, also the dropped ones. + value_rows: usize, + /// True after `add_directional_regularisation`. + uses_directional: bool, + /// Start value of the solve (one value for each node). `None`: zero. + /// The session takes it when it starts. + pub(crate) start: Option>, +} + +impl fmt::Debug for Problem { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Problem") + .field("grid", &self.interp().grid) + .field("warnings", &self.warnings) + .finish_non_exhaustive() + } +} + +impl Problem { + fn interp(&self) -> &FiniteDifferenceInterpolator { + self.interp.as_ref().expect("interpolator is present") + } + + fn interp_mut(&mut self) -> &mut FiniteDifferenceInterpolator { + self.interp.as_mut().expect("interpolator is present") + } + + /// Change the interpolator with a method that takes it by value. + fn with_interp( + &mut self, + f: impl FnOnce(FiniteDifferenceInterpolator) -> FiniteDifferenceInterpolator, + ) { + let old = self.interp.take().expect("interpolator is present"); + self.interp = Some(f(old)); + } + + /// Number of constraint rows of all families. + fn n_constraints(&self) -> usize { + let i = self.interp(); + i.n_value_constraints() + + i.n_gradient_constraints() + + i.n_norm_constraints() + + i.n_tangent_constraints() + + i.n_interface_constraints() + + i.n_inequality_constraints() + } + + /// Count the points that are outside the grid. + fn count_outside(&self, points: &[f64]) -> usize { + let grid = &self.interp().grid; + (0..points.len() / 3) + .filter(|&i| grid.trilinear_coeffs(row3(points, i)).is_none()) + .count() + } + + /// Add one warning if `outside` is more than zero. + fn warn_outside(&mut self, family: &str, outside: usize) { + if outside > 0 { + self.warnings.push(format!( + "{family}: {outside} point(s) outside the grid were dropped" + )); + } + } + + pub fn new(origin: [f64; 3], counts: [usize; 3], step: [f64; 3]) -> Result { + // Check the sizes first. Do this before any large allocation. + for (axis, &nodes) in counts.iter().enumerate() { + if nodes < 3 { + return Err(CoreError::GridTooSmall { axis, nodes }); + } + } + let nodes = (counts[0] as u128) + .saturating_mul(counts[1] as u128) + .saturating_mul(counts[2] as u128); + if nodes > MAX_NODES as u128 { + return Err(CoreError::GridTooLarge { + nodes: u64::try_from(nodes).unwrap_or(u64::MAX), + max_nodes: MAX_NODES, + }); + } + if !origin.iter().all(|v| v.is_finite()) { + return Err(bad_input("grid", "origin is not finite")); + } + if !step.iter().all(|v| v.is_finite() && *v > 0.0) { + return Err(bad_input("grid", "step must be finite and more than 0")); + } + let mut warnings = Vec::new(); + let max_step = step.iter().cloned().fold(f64::MIN, f64::max); + let min_step = step.iter().cloned().fold(f64::MAX, f64::min); + if max_step / min_step > 100.0 { + warnings.push(format!( + "grid step ratio {:.1} is more than 100; the result can be poor", + max_step / min_step + )); + } + // The ported constructor builds tables (about 7 bytes per node). + // Task T018 makes this lazy. + let interp = FiniteDifferenceInterpolator::new(StructuredGrid3D::new(origin, counts, step)); + Ok(Problem { + interp: Some(interp), + warnings, + seen_values: Some(HashMap::new()), + value_rows: 0, + uses_directional: false, + start: None, + }) + } + + pub fn set_regularisation(&mut self, w: RegularisationWeights) { + self.with_interp(|i| i.with_regularisation(w)); + } + + /// Set the ridge factor. The normal operator gets `ridge_factor^2 * I`. + /// 0 removes the ridge term. The default is 1e-8. + pub fn set_ridge_factor(&mut self, ridge_factor: f64) -> Result<(), CoreError> { + if !(ridge_factor.is_finite() && ridge_factor >= 0.0) { + return Err(bad_input("ridge_factor", "must be finite and 0 or more")); + } + self.interp_mut().ridge_factor = ridge_factor; + Ok(()) + } + + /// Set the start value of the solve, one value for each node in the + /// node order of the grid. Without a start value the solve starts at 0. + pub fn set_start(&mut self, x0: &[f64]) -> Result<(), CoreError> { + const F: &str = "start"; + check_len(F, "x0", x0.len(), self.interp().dof())?; + if let Some(i) = x0.iter().position(|v| !v.is_finite()) { + return Err(bad_row(F, i, "value is not finite")); + } + let mut v: Vec = Vec::new(); + v.try_reserve_exact(x0.len()) + .map_err(|_| CoreError::OutOfMemory { + requested_bytes: (x0.len() as u64).saturating_mul(8), + })?; + v.extend_from_slice(x0); + self.start = Some(v); + Ok(()) + } + + pub fn set_border_regularisation(&mut self, w: BorderRegularisationWeights) { + self.with_interp(|i| i.with_border_regularisation(w)); + } + + pub fn add_value( + &mut self, + points: &[f64], + values: &[f64], + weights: &[f64], + ) -> Result<(), CoreError> { + const F: &str = "value"; + let n = count_points(F, "points", points)?; + check_len(F, "values", values.len(), n)?; + check_len(F, "weights", weights.len(), n)?; + for i in 0..n { + check_finite3(F, points, i, "point")?; + if !values[i].is_finite() { + return Err(bad_row(F, i, "value is not finite")); + } + check_weight(F, i, weights[i])?; + } + // Decide if the conflict check runs. + if self.seen_values.is_some() && self.value_rows.saturating_add(n) >= CONFLICT_CHECK_LIMIT { + self.seen_values = None; + self.warnings.push( + "value: 1000000 rows or more; the check for conflicting values was skipped".into(), + ); + } + self.value_rows = self.value_rows.saturating_add(n); + let mut outside = 0usize; + let mut conflicts = 0usize; + // Upper bound: rows outside the grid are dropped later. + self.interp_mut().reserve_value(n); + for i in 0..n { + let p = row3(points, i); + if self.interp().grid.trilinear_coeffs(p).is_none() { + outside += 1; + continue; + } + if let Some(seen) = self.seen_values.as_mut() { + // Adding 0.0 turns -0.0 into 0.0, so both give the same key. + let key = [ + (p[0] + 0.0).to_bits(), + (p[1] + 0.0).to_bits(), + (p[2] + 0.0).to_bits(), + ]; + match seen.get(&key) { + Some(&v) if v != values[i] => conflicts += 1, + Some(_) => {} + None => { + seen.insert(key, values[i]); + } + } + } + self.interp_mut() + .add_value_constraint(p, values[i], weights[i]); + } + self.warn_outside(F, outside); + if conflicts > 0 { + self.warnings.push(format!( + "value: {conflicts} row(s) have a different value from an earlier row at the same point" + )); + } + Ok(()) + } + + /// Shared code for the gradient, normal and tangent families. + fn add_vectors( + &mut self, + family: &str, + points: &[f64], + vectors: &[f64], + weights: &[f64], + add: impl Fn(&mut FiniteDifferenceInterpolator, [f64; 3], [f64; 3], f64), + ) -> Result<(), CoreError> { + let n = count_points(family, "points", points)?; + check_len(family, "vectors", vectors.len(), 3 * n)?; + check_len(family, "weights", weights.len(), n)?; + for (i, &w) in weights.iter().enumerate() { + check_finite3(family, points, i, "point")?; + check_vector(family, vectors, i)?; + check_weight(family, i, w)?; + } + let outside = self.count_outside(points); + // Reserve exactly for the rows that are in the grid. + let valid = n - outside; + match family { + "gradient" => self.interp_mut().reserve_gradient(valid), + "normal" => self.interp_mut().reserve_norm(valid), + "tangent" => self.interp_mut().reserve_tangent(valid), + _ => {} + } + for (i, &w) in weights.iter().enumerate() { + add(self.interp_mut(), row3(points, i), row3(vectors, i), w); + } + self.warn_outside(family, outside); + Ok(()) + } + + /// Gradient constraint, direction only. + pub fn add_gradient( + &mut self, + points: &[f64], + vectors: &[f64], + weights: &[f64], + ) -> Result<(), CoreError> { + self.add_vectors("gradient", points, vectors, weights, |i, p, v, w| { + i.add_gradient_constraint(p, v, w) + }) + } + + /// Normal constraint, with length. + pub fn add_normal( + &mut self, + points: &[f64], + vectors: &[f64], + weights: &[f64], + ) -> Result<(), CoreError> { + self.add_vectors("normal", points, vectors, weights, |i, p, v, w| { + i.add_norm_constraint(p, v, w) + }) + } + + pub fn add_tangent( + &mut self, + points: &[f64], + vectors: &[f64], + weights: &[f64], + ) -> Result<(), CoreError> { + self.add_vectors("tangent", points, vectors, weights, |i, p, v, w| { + i.add_tangent_constraint(p, v, w) + }) + } + + pub fn add_interface( + &mut self, + points: &[f64], + ids: &[f64], + weight: f64, + ) -> Result<(), CoreError> { + const F: &str = "interface"; + let n = count_points(F, "points", points)?; + check_len(F, "ids", ids.len(), n)?; + if !(weight.is_finite() && weight > 0.0) { + return Err(bad_input(F, "weight must be finite and more than 0")); + } + for i in 0..n { + check_finite3(F, points, i, "point")?; + } + let outside = self.count_outside(points); + let pts: Vec<[f64; 3]> = (0..n).map(|i| row3(points, i)).collect(); + // Upper bound: at most one row for each point. + self.interp_mut().reserve_interface(n); + self.interp_mut() + .add_interface_constraints(&pts, ids, weight); + self.warn_outside(F, outside); + Ok(()) + } + + pub fn add_directional_regularisation( + &mut self, + directions: &[f64], + weight: f64, + ) -> Result<(), CoreError> { + const F: &str = "directional_regularisation"; + let dof = self.interp().dof(); + check_len(F, "directions", directions.len(), 3 * dof)?; + if !(weight.is_finite() && weight > 0.0) { + return Err(bad_input(F, "weight must be finite and more than 0")); + } + for i in 0..dof { + check_finite3(F, directions, i, "direction")?; + } + let dirs: Vec<[f64; 3]> = (0..dof).map(|i| row3(directions, i)).collect(); + self.interp_mut() + .add_directional_regularisation(&dirs, weight); + self.uses_directional = true; + Ok(()) + } + + pub fn add_value_bound( + &mut self, + points: &[f64], + lower: &[f64], + upper: &[f64], + weights: &[f64], + ) -> Result<(), CoreError> { + const F: &str = "value_bound"; + let n = count_points(F, "points", points)?; + check_len(F, "lower", lower.len(), n)?; + check_len(F, "upper", upper.len(), n)?; + check_len(F, "weights", weights.len(), n)?; + for i in 0..n { + check_finite3(F, points, i, "point")?; + check_bounds(F, i, lower[i], upper[i])?; + check_weight(F, i, weights[i])?; + } + let outside = self.count_outside(points); + self.interp_mut().reserve_value_inequality(n - outside); + for i in 0..n { + self.interp_mut().add_value_inequality_constraint( + row3(points, i), + lower[i], + upper[i], + weights[i], + ); + } + self.warn_outside(F, outside); + Ok(()) + } + + pub fn add_pair_bound( + &mut self, + points_a: &[f64], + points_b: &[f64], + lower: &[f64], + upper: &[f64], + weights: &[f64], + ) -> Result<(), CoreError> { + const F: &str = "pair_bound"; + let n = count_points(F, "points_a", points_a)?; + check_len(F, "points_b", points_b.len(), 3 * n)?; + check_len(F, "lower", lower.len(), n)?; + check_len(F, "upper", upper.len(), n)?; + check_len(F, "weights", weights.len(), n)?; + for i in 0..n { + check_finite3(F, points_a, i, "point a")?; + check_finite3(F, points_b, i, "point b")?; + check_bounds(F, i, lower[i], upper[i])?; + check_weight(F, i, weights[i])?; + } + // A row is dropped when point a or point b is outside the grid. + let outside = { + let grid = &self.interp().grid; + (0..n) + .filter(|&i| { + grid.trilinear_coeffs(row3(points_a, i)).is_none() + || grid.trilinear_coeffs(row3(points_b, i)).is_none() + }) + .count() + }; + self.interp_mut().reserve_pair_inequality(n - outside); + for i in 0..n { + self.interp_mut().add_inequality_pair_constraint( + row3(points_a, i), + row3(points_b, i), + lower[i], + upper[i], + weights[i], + ); + } + self.warn_outside(F, outside); + Ok(()) + } + + pub fn warnings(&self) -> &[String] { + &self.warnings + } + + /// Fill the counts of the problem. This allocates nothing. + fn counts(&self) -> ProblemCounts { + let i = self.interp(); + ProblemCounts { + dof: i.dof() as u64, + n_value: i.n_value_constraints() as u64, + n_gradient: i.n_gradient_constraints() as u64, + n_normal: i.n_norm_constraints() as u64, + n_tangent: i.n_tangent_constraints() as u64, + n_interface: i.n_interface_constraints() as u64, + n_pair_bound: i.n_inequality_pair_constraints() as u64, + n_value_bound: i.n_value_inequality_constraints() as u64, + uses_directional: self.uses_directional, + has_start: self.start.is_some(), + class_stencil: !self.uses_directional + && i.grid + .nsteps + .iter() + .all(|&n| n >= crate::class_stencil::MIN_AXIS_NODES), + } + } + + pub fn estimate_memory(&self, s: &Settings) -> Result { + Ok(estimate(&self.counts(), s)) + } + + /// Recompute the relative residual of `field` in double precision. + /// The check is independent of the precision of the solve. + pub fn residual_f64(&self, field: &Field) -> f64 { + self.interp().residual_f64_of(&field.values_f64()) + } + + /// Start a solve session. This consumes the problem. + pub fn start(self, s: Settings) -> Result { + // Check the budget first. Nothing is allocated here. + if let Some(budget) = s.budget_bytes { + let counts = self.counts(); + let e = estimate(&counts, &s); + if e.total > budget { + return Err(CoreError::OverBudget { + estimate_bytes: e.total, + budget_bytes: budget, + max_nodes_per_axis: max_grid_for_budget(&counts, &s, budget), + }); + } + } + if self.n_constraints() == 0 { + return Err(CoreError::NoConstraints); + } + SolveSession::new(self, &s) + } + + /// The interpolator, for the solve session. + pub(crate) fn interpolator(&self) -> &FiniteDifferenceInterpolator { + self.interp() + } + + /// The interpolator, for the solve session (setup). + pub(crate) fn interpolator_mut(&mut self) -> &mut FiniteDifferenceInterpolator { + self.interp_mut() + } + + /// Solve in one call: a loop over `step`. + pub fn solve(self, s: Settings) -> Result<(Field, SolveReport), CoreError> { + let mut session = self.start(s)?; + // One step is large, so the loop has few turns. The CG state stops + // at `max_iter` by itself. + while let Step::Running(_) = session.step(1 << 20)? {} + session.finish() + } +} diff --git a/packages/loop_interpolation_core/src/python.rs b/packages/loop_interpolation_core/src/python.rs new file mode 100644 index 00000000..7cc1cfae --- /dev/null +++ b/packages/loop_interpolation_core/src/python.rs @@ -0,0 +1,405 @@ +//! Python bindings (PyO3). Compiled only with the feature `python`. +//! +//! The module `loop_interpolation_core` has one class, `Problem`. +//! Arrays come in as contiguous `float64` numpy arrays. +//! `solve` releases the GIL and consumes the problem. + +use numpy::{PyArray1, PyReadonlyArray1}; +use pyo3::exceptions::{PyMemoryError, PyNotImplementedError, PyRuntimeError, PyValueError}; +use pyo3::prelude::*; +use pyo3::types::{PyDict, PyList}; + +use crate::error::{BoundKind, CoreError}; +use crate::field::FieldData; +use crate::interpolator::{BorderRegularisationWeights, RegularisationWeights}; +use crate::problem::Problem; +use crate::report::SolveReport; +use crate::settings::{Precision, Settings}; + +/// Make a Python error from a core error. The text of the error stays. +fn map_err(e: CoreError) -> PyErr { + let text = e.to_string(); + match e { + CoreError::InvalidInput { .. } + | CoreError::GridTooSmall { .. } + | CoreError::GridTooLarge { .. } + | CoreError::NoConstraints + | CoreError::PointOutsideGrid { .. } => PyValueError::new_err(text), + CoreError::OutOfMemory { .. } | CoreError::OverBudget { .. } => { + PyMemoryError::new_err(text) + } + CoreError::Infeasible { worst } => { + let rows: Vec = worst + .iter() + .map(|w| { + let kind = match w.kind { + BoundKind::Value => "value", + BoundKind::Pair => "pair", + }; + format!("{kind}[{}] violation {:e}", w.index, w.violation) + }) + .collect(); + PyRuntimeError::new_err(format!("{text}: {}", rows.join("; "))) + } + CoreError::SolveRunning => PyRuntimeError::new_err(text), + CoreError::Unsupported { .. } => PyNotImplementedError::new_err(text), + } +} + +/// Get a contiguous slice of an array, or raise `ValueError`. +fn slice<'a>(name: &str, a: &'a PyReadonlyArray1<'_, f64>) -> PyResult<&'a [f64]> { + a.as_slice() + .map_err(|_| PyValueError::new_err(format!("array '{name}' must be contiguous"))) +} + +/// Read a number from a dict value. +fn num(key: &str, v: &Bound<'_, PyAny>) -> PyResult { + v.extract::() + .map_err(|_| PyValueError::new_err(format!("value of '{key}' must be a number"))) +} + +/// Read an unsigned integer from a dict value. +fn uint(key: &str, v: &Bound<'_, PyAny>) -> PyResult { + v.extract::().map_err(|_| { + PyValueError::new_err(format!("value of '{key}' must be a non-negative integer")) + }) +} + +/// Read a settings dict. An unknown key raises `ValueError`. +fn parse_settings(d: &Bound<'_, PyDict>) -> PyResult { + let mut s = Settings::default(); + for (k, v) in d.iter() { + let key: String = k + .extract() + .map_err(|_| PyValueError::new_err("settings keys must be text"))?; + match key.as_str() { + "precision" => { + let p: String = v + .extract() + .map_err(|_| PyValueError::new_err("'precision' must be \"f64\" or \"f32\""))?; + s.precision = match p.as_str() { + "f64" => Precision::F64, + "f32" => Precision::F32, + other => { + return Err(PyValueError::new_err(format!( + "unknown precision '{other}'; use \"f64\" or \"f32\"" + ))); + } + }; + } + "tol" => s.tol = num(&key, &v)?, + "max_iter" => s.max_iter = uint(&key, &v)? as usize, + "budget_bytes" => { + s.budget_bytes = if v.is_none() { + None + } else { + Some(uint(&key, &v)?) + } + } + "rho" => s.rho = num(&key, &v)?, + "inner_max_iter" => s.inner_max_iter = uint(&key, &v)? as usize, + "inner_tol" => s.inner_tol = num(&key, &v)?, + "admm_abs_tol" => s.admm_abs_tol = num(&key, &v)?, + "admm_rel_tol" => s.admm_rel_tol = num(&key, &v)?, + other => { + return Err(PyValueError::new_err(format!("unknown setting '{other}'"))); + } + } + } + Ok(s) +} + +/// Make a Python dict from a solve report. +fn report_dict<'py>(py: Python<'py>, r: &SolveReport) -> PyResult> { + let d = PyDict::new(py); + d.set_item("converged", r.converged)?; + d.set_item("iterations", r.iterations)?; + d.set_item("residual", r.residual)?; + d.set_item("max_violation", r.max_violation)?; + d.set_item("peak_bytes", r.peak_bytes)?; + d.set_item("warnings", PyList::new(py, &r.warnings)?)?; + Ok(d) +} + +/// Read the weights of a dict. A key that is not in `allowed` raises `ValueError`. +/// Return the value for each allowed key, or `None` if the key is not set. +fn weights_from_dict(d: &Bound<'_, PyDict>, allowed: &[&str]) -> PyResult>> { + let mut out = vec![None; allowed.len()]; + for (k, v) in d.iter() { + let key: String = k + .extract() + .map_err(|_| PyValueError::new_err("keys must be text"))?; + match allowed.iter().position(|a| *a == key) { + Some(i) => out[i] = Some(num(&key, &v)?), + None => { + return Err(PyValueError::new_err(format!( + "unknown key '{key}'; allowed keys: {}", + allowed.join(", ") + ))); + } + } + } + Ok(out) +} + +/// Read a list of three numbers. +fn triple(name: &str, v: &Bound<'_, PyAny>) -> PyResult<[f64; 3]> { + let x: Vec = v + .extract() + .map_err(|_| PyValueError::new_err(format!("'{name}' must have 3 numbers")))?; + <[f64; 3]>::try_from(x) + .map_err(|_| PyValueError::new_err(format!("'{name}' must have 3 numbers"))) +} + +/// A low-memory interpolation problem on a regular 3D grid. +#[pyclass(name = "Problem")] +struct PyProblem { + /// `None` after `solve`. A problem can be solved one time. + inner: Option, +} + +impl PyProblem { + fn get(&mut self) -> PyResult<&mut Problem> { + self.inner + .as_mut() + .ok_or_else(|| PyRuntimeError::new_err("problem already solved")) + } +} + +#[pymethods] +impl PyProblem { + /// Make a problem. `origin`, `counts` and `step` each have 3 values. + #[new] + fn new( + origin: &Bound<'_, PyAny>, + counts: &Bound<'_, PyAny>, + step: &Bound<'_, PyAny>, + ) -> PyResult { + let origin = triple("origin", origin)?; + let step = triple("step", step)?; + let counts: Vec = counts + .extract() + .map_err(|_| PyValueError::new_err("'counts' must have 3 positive integers"))?; + let counts = <[usize; 3]>::try_from(counts) + .map_err(|_| PyValueError::new_err("'counts' must have 3 positive integers"))?; + let p = Problem::new(origin, counts, step).map_err(map_err)?; + Ok(Self { inner: Some(p) }) + } + + /// Set the regularisation weights. Keys: dxx dyy dzz dxy dxz dyz. + fn set_regularisation(&mut self, weights: &Bound<'_, PyDict>) -> PyResult<()> { + let v = weights_from_dict(weights, &["dxx", "dyy", "dzz", "dxy", "dxz", "dyz"])?; + let mut w = RegularisationWeights::default(); + if let Some(x) = v[0] { + w.dxx = x; + } + if let Some(x) = v[1] { + w.dyy = x; + } + if let Some(x) = v[2] { + w.dzz = x; + } + if let Some(x) = v[3] { + w.dxy = x; + } + if let Some(x) = v[4] { + w.dxz = x; + } + if let Some(x) = v[5] { + w.dyz = x; + } + self.get()?.set_regularisation(w); + Ok(()) + } + + /// Set the border regularisation weights. Keys: dx dy dz. + fn set_border_regularisation(&mut self, weights: &Bound<'_, PyDict>) -> PyResult<()> { + let v = weights_from_dict(weights, &["dx", "dy", "dz"])?; + let mut w = BorderRegularisationWeights::default(); + if let Some(x) = v[0] { + w.dx = x; + } + if let Some(x) = v[1] { + w.dy = x; + } + if let Some(x) = v[2] { + w.dz = x; + } + self.get()?.set_border_regularisation(w); + Ok(()) + } + + /// Set the ridge factor. 0 removes the ridge term. The default is 1e-8. + fn set_ridge_factor(&mut self, ridge_factor: f64) -> PyResult<()> { + self.get()?.set_ridge_factor(ridge_factor).map_err(map_err) + } + + /// Set the start value of the solve. `x0` has one value for each node. + fn set_start(&mut self, x0: PyReadonlyArray1<'_, f64>) -> PyResult<()> { + let x = slice("x0", &x0)?; + self.get()?.set_start(x).map_err(map_err) + } + + /// Add value constraints. + fn add_value( + &mut self, + points: PyReadonlyArray1<'_, f64>, + values: PyReadonlyArray1<'_, f64>, + weights: PyReadonlyArray1<'_, f64>, + ) -> PyResult<()> { + let (p, v, w) = ( + slice("points", &points)?, + slice("values", &values)?, + slice("weights", &weights)?, + ); + self.get()?.add_value(p, v, w).map_err(map_err) + } + + /// Add gradient constraints (direction only). + fn add_gradient( + &mut self, + points: PyReadonlyArray1<'_, f64>, + vectors: PyReadonlyArray1<'_, f64>, + weights: PyReadonlyArray1<'_, f64>, + ) -> PyResult<()> { + let (p, v, w) = ( + slice("points", &points)?, + slice("vectors", &vectors)?, + slice("weights", &weights)?, + ); + self.get()?.add_gradient(p, v, w).map_err(map_err) + } + + /// Add normal constraints (the vector length counts). + fn add_normal( + &mut self, + points: PyReadonlyArray1<'_, f64>, + vectors: PyReadonlyArray1<'_, f64>, + weights: PyReadonlyArray1<'_, f64>, + ) -> PyResult<()> { + let (p, v, w) = ( + slice("points", &points)?, + slice("vectors", &vectors)?, + slice("weights", &weights)?, + ); + self.get()?.add_normal(p, v, w).map_err(map_err) + } + + /// Add tangent constraints. + fn add_tangent( + &mut self, + points: PyReadonlyArray1<'_, f64>, + vectors: PyReadonlyArray1<'_, f64>, + weights: PyReadonlyArray1<'_, f64>, + ) -> PyResult<()> { + let (p, v, w) = ( + slice("points", &points)?, + slice("vectors", &vectors)?, + slice("weights", &weights)?, + ); + self.get()?.add_tangent(p, v, w).map_err(map_err) + } + + /// Add interface constraints. Points with the same id have the same value. + fn add_interface( + &mut self, + points: PyReadonlyArray1<'_, f64>, + ids: PyReadonlyArray1<'_, f64>, + weight: f64, + ) -> PyResult<()> { + let (p, i) = (slice("points", &points)?, slice("ids", &ids)?); + self.get()?.add_interface(p, i, weight).map_err(map_err) + } + + /// Add directional regularisation. `directions` has 3 values for each node. + fn add_directional_regularisation( + &mut self, + directions: PyReadonlyArray1<'_, f64>, + weight: f64, + ) -> PyResult<()> { + let d = slice("directions", &directions)?; + self.get()? + .add_directional_regularisation(d, weight) + .map_err(map_err) + } + + /// Add value bounds. A bound of -inf or +inf means no bound. + fn add_value_bound( + &mut self, + points: PyReadonlyArray1<'_, f64>, + lower: PyReadonlyArray1<'_, f64>, + upper: PyReadonlyArray1<'_, f64>, + weights: PyReadonlyArray1<'_, f64>, + ) -> PyResult<()> { + let (p, l, u, w) = ( + slice("points", &points)?, + slice("lower", &lower)?, + slice("upper", &upper)?, + slice("weights", &weights)?, + ); + self.get()?.add_value_bound(p, l, u, w).map_err(map_err) + } + + /// Add pair bounds on the difference of the field at two points. + fn add_pair_bound( + &mut self, + points_a: PyReadonlyArray1<'_, f64>, + points_b: PyReadonlyArray1<'_, f64>, + lower: PyReadonlyArray1<'_, f64>, + upper: PyReadonlyArray1<'_, f64>, + weights: PyReadonlyArray1<'_, f64>, + ) -> PyResult<()> { + let (a, b, l, u, w) = ( + slice("points_a", &points_a)?, + slice("points_b", &points_b)?, + slice("lower", &lower)?, + slice("upper", &upper)?, + slice("weights", &weights)?, + ); + self.get()?.add_pair_bound(a, b, l, u, w).map_err(map_err) + } + + /// Estimate the memory of a solve. Return a dict with the keys + /// vectors, constraints, working and total (bytes). + fn estimate<'py>( + &mut self, + py: Python<'py>, + settings: &Bound<'py, PyDict>, + ) -> PyResult> { + let s = parse_settings(settings)?; + let e = self.get()?.estimate_memory(&s).map_err(map_err)?; + let d = PyDict::new(py); + d.set_item("vectors", e.vectors)?; + d.set_item("constraints", e.constraints)?; + d.set_item("working", e.working)?; + d.set_item("total", e.total)?; + Ok(d) + } + + /// Solve the problem. Return the field (numpy array) and a report dict. + /// The problem is used up. A second call raises `RuntimeError`. + fn solve<'py>( + &mut self, + py: Python<'py>, + settings: &Bound<'py, PyDict>, + ) -> PyResult<(Bound<'py, PyAny>, Bound<'py, PyDict>)> { + let s = parse_settings(settings)?; + let problem = self + .inner + .take() + .ok_or_else(|| PyRuntimeError::new_err("problem already solved"))?; + // The solve does not use Python objects. Release the GIL. + let (field, report) = py.detach(move || problem.solve(s)).map_err(map_err)?; + let array: Bound<'py, PyAny> = match field.data { + FieldData::F64(v) => PyArray1::from_vec(py, v).into_any(), + FieldData::F32(v) => PyArray1::from_vec(py, v).into_any(), + }; + Ok((array, report_dict(py, &report)?)) + } +} + +#[pymodule] +fn loop_interpolation_core(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + Ok(()) +} diff --git a/packages/loop_interpolation_core/src/report.rs b/packages/loop_interpolation_core/src/report.rs new file mode 100644 index 00000000..8a84f352 --- /dev/null +++ b/packages/loop_interpolation_core/src/report.rs @@ -0,0 +1,45 @@ +//! Progress and result reports of a solve. + +/// Stage of a solve. +#[derive(Debug, Clone)] +pub enum Stage { + Setup, + Solve, + CheckConstraints, +} + +/// Progress of a running solve. +#[derive(Debug, Clone)] +pub struct Progress { + pub stage: Stage, + pub iteration: usize, + pub residual: f64, + pub max_violation: f64, +} + +/// Result report of a solve. +#[derive(Debug, Clone)] +pub struct SolveReport { + pub converged: bool, + pub iterations: usize, + pub residual: f64, + pub max_violation: f64, + pub peak_bytes: u64, + pub warnings: Vec, +} + +/// Result of one `step` call. +#[derive(Debug, Clone)] +pub enum Step { + Running(Progress), + Done(SolveReport), +} + +/// Memory estimate in bytes. +#[derive(Debug, Clone)] +pub struct MemoryEstimate { + pub vectors: u64, + pub constraints: u64, + pub working: u64, + pub total: u64, +} diff --git a/packages/loop_interpolation_core/src/scalar.rs b/packages/loop_interpolation_core/src/scalar.rs new file mode 100644 index 00000000..0c9033ff --- /dev/null +++ b/packages/loop_interpolation_core/src/scalar.rs @@ -0,0 +1,65 @@ +//! Scalar trait for the vector element type (f32 or f64). +//! +//! Stencil coefficients stay f64. Each sum runs in f64. Only the stored +//! vector values use the type `Scalar`. + +use std::ops::{Add, AddAssign, Mul, Sub}; + +/// Element type of a solver vector. Implemented for `f32` and `f64`. +pub trait Scalar: + Copy + + Send + + Sync + + Default + + 'static + + Add + + Sub + + Mul + + AddAssign +{ + /// Convert from f64 (rounds for f32). + fn from_f64(x: f64) -> Self; + /// Convert to f64 (exact). + fn to_f64(self) -> f64; +} + +impl Scalar for f64 { + #[inline] + fn from_f64(x: f64) -> Self { + x + } + #[inline] + fn to_f64(self) -> f64 { + self + } +} + +impl Scalar for f32 { + #[inline] + fn from_f64(x: f64) -> Self { + x as f32 + } + #[inline] + fn to_f64(self) -> f64 { + self as f64 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn f64_round_trip_is_exact() { + let v = 0.123456789012345_f64; + assert_eq!(f64::from_f64(v).to_f64(), v); + } + + #[test] + fn f32_round_trip_is_close() { + let v = 0.123456789_f64; + let r = f32::from_f64(v).to_f64(); + assert!((r - v).abs() / v < 1e-7); + assert_eq!(f32::from_f64(0.5).to_f64(), 0.5); + } +} diff --git a/packages/loop_interpolation_core/src/session.rs b/packages/loop_interpolation_core/src/session.rs new file mode 100644 index 00000000..f222f22b --- /dev/null +++ b/packages/loop_interpolation_core/src/session.rs @@ -0,0 +1,469 @@ +//! Solve session: a state machine that gives control back often. + +use crate::admm::{AdmmOutcome, AdmmState}; +use crate::cg::{CgState, CgStatus}; +use crate::error::CoreError; +use crate::field::{Field, FieldData}; +use crate::interpolator::FiniteDifferenceInterpolator; +use crate::memory::MemTracker; +use crate::problem::Problem; +use crate::report::{Progress, SolveReport, Stage, Step}; +use crate::scalar::Scalar; +use crate::settings::{Precision, Settings}; + +/// The solver of a session: plain CG, or ADMM if the problem has bounds. +#[derive(Debug)] +enum Solver { + Cg(CgState), + Admm(AdmmState), +} + +/// Result of one step of a `State`. +enum Out { + Running { + check: bool, + iteration: usize, + residual: f64, + max_violation: f64, + }, + /// The solve has ended. The value is true if the solver says it converged. + Done(bool), +} + +/// Solver and scratch buffer, with the vector type `S` (`f64` or `f32`). +#[derive(Debug)] +struct State { + solver: Solver, + /// Scratch of the normal operator. It is allocated once. + scratch: Vec, +} + +impl State { + /// Allocate the solver vectors and the scratch buffer from `tracker`. + /// With `x0` the solver starts from `x0`, not from zero. + fn new( + interp: &FiniteDifferenceInterpolator, + tracker: &mut MemTracker, + s: &Settings, + x0: Option<&[f64]>, + ) -> Result { + // The `f32` scratch has no border part if the problem needs none. + // The `f64` scratch is not changed. + let scratch_len = if std::mem::size_of::() == 4 { + interp.scratch_len_compact() + } else { + interp.scratch_len() + }; + let solver = if interp.n_inequality_constraints() > 0 { + Solver::Admm(AdmmState::new(tracker, interp, s)?) + } else { + // `rhs` becomes `r` in the CG state. It is not copied. + let mut rhs: Vec = tracker.try_zeroed(interp.dof())?; + interp.write_data_rhs(&mut rhs); + let diag = interp.try_diagonal(tracker)?; + Solver::Cg(CgState::new(tracker, rhs, diag, s.tol, s.max_iter)?) + }; + let mut scratch: Vec = tracker.try_zeroed(scratch_len)?; + let mut solver = solver; + if let Some(x0) = x0 { + match &mut solver { + Solver::Cg(cg) => { + let mut op = |v: &[S], out: &mut [S]| { + interp.apply_normal_operator_into(v, &mut scratch, out); + }; + cg.restart_from(x0, &mut op); + } + Solver::Admm(admm) => admm.restart_from(x0), + } + } + Ok(Self { solver, scratch }) + } + + fn iterations_residual_violation(&self) -> (usize, f64, f64) { + match &self.solver { + Solver::Cg(cg) => (cg.iterations(), cg.residual(), 0.0), + Solver::Admm(a) => (a.iterations(), a.residual(), a.max_violation()), + } + } + + fn x(&self) -> &[S] { + match &self.solver { + Solver::Cg(cg) => &cg.x, + Solver::Admm(a) => &a.x, + } + } + + fn x_mut(&mut self) -> &mut Vec { + match &mut self.solver { + Solver::Cg(cg) => &mut cg.x, + Solver::Admm(a) => &mut a.x, + } + } + + fn into_x(self) -> Vec { + match self.solver { + Solver::Cg(cg) => cg.x, + Solver::Admm(a) => a.x, + } + } + + fn is_cg(&self) -> bool { + matches!(self.solver, Solver::Cg(_)) + } + + /// Text that tells why a single precision CG run stopped early. + fn stop_reason(&self) -> Option<&'static str> { + match &self.solver { + Solver::Cg(cg) => cg.stop_reason(), + Solver::Admm(_) => None, + } + } + + /// Free all work vectors and the scratch buffer. Only `x` stays. + /// Call it only when the run is done. + fn release_work(&mut self, tracker: &mut MemTracker) { + match &mut self.solver { + Solver::Cg(cg) => cg.release_work(tracker), + Solver::Admm(a) => a.release_work(tracker), + } + let bytes = (self.scratch.len() as u64).saturating_mul(std::mem::size_of::() as u64); + self.scratch = Vec::new(); + tracker.release(bytes); + } + + /// Run at most `max_iters` iterations. + fn step( + &mut self, + interp: &FiniteDifferenceInterpolator, + tracker: &mut MemTracker, + max_iters: usize, + ) -> Result { + let scratch = &mut self.scratch; + match &mut self.solver { + Solver::Cg(cg) => { + let mut op = |v: &[S], out: &mut [S]| { + interp.apply_normal_operator_into(v, scratch, out); + }; + Ok(match cg.step(&mut op, max_iters) { + CgStatus::Running { + iteration, + residual, + } => Out::Running { + check: false, + iteration, + residual, + max_violation: 0.0, + }, + CgStatus::Converged => Out::Done(true), + CgStatus::MaxIter | CgStatus::Breakdown => Out::Done(false), + }) + } + Solver::Admm(admm) => Ok(match admm.step(tracker, interp, scratch, max_iters)? { + AdmmOutcome::Running { + check, + iteration, + residual, + max_violation, + } => Out::Running { + check, + iteration, + residual, + max_violation, + }, + AdmmOutcome::Done(converged) => Out::Done(converged), + }), + } + } +} + +/// The state of a session, in the precision of `Settings.precision`. +#[derive(Debug)] +enum Inner { + F64(State), + F32(State), +} + +/// Copy a slice into a new vector. A failed allocation is an error. +fn copy_vec(src: &[S]) -> Result, CoreError> { + let mut x: Vec = Vec::new(); + x.try_reserve_exact(src.len()) + .map_err(|_| CoreError::OutOfMemory { + requested_bytes: (src.len() as u64).saturating_mul(std::mem::size_of::() as u64), + })?; + x.extend_from_slice(src); + Ok(x) +} + +/// A running solve. The session owns the problem. Drop it to cancel. +#[derive(Debug)] +pub struct SolveSession { + problem: Problem, + inner: Inner, + tracker: MemTracker, + stage: Stage, + /// Set when the solve has ended: true if it converged. + done: Option, + /// Tolerance of the solve. + tol: f64, + /// Single precision CG: the residual from `x`, computed again in `f64`. + final_residual: Option, + /// Warnings that the solver adds to the report. + notes: Vec, + /// Set when `finalize` fails. The session is then broken: every later + /// `step` returns this error. The state is not valid any more. + failed: Option, + /// Test only: make the next `finalize` fail once with this error. + #[cfg(test)] + inject_finalize_error: Option, +} + +impl SolveSession { + /// Set up the solve. The stage is `Setup` until the first `step`. + pub(crate) fn new(mut problem: Problem, s: &Settings) -> Result { + let mut tracker = MemTracker::new(s.budget_bytes); + problem.interpolator_mut().prepare(&mut tracker)?; + // The solver copies the start value. Then it is freed. + let x0 = problem.start.take(); + let x0 = x0.as_deref(); + let interp = problem.interpolator(); + let inner = match s.precision { + Precision::F64 => Inner::F64(State::new(interp, &mut tracker, s, x0)?), + Precision::F32 => Inner::F32(State::new(interp, &mut tracker, s, x0)?), + }; + Ok(Self { + problem, + inner, + tracker, + stage: Stage::Setup, + done: None, + tol: s.tol, + final_residual: None, + notes: Vec::new(), + failed: None, + #[cfg(test)] + inject_finalize_error: None, + }) + } + + fn report(&self, converged: bool) -> SolveReport { + let (iterations, residual, max_violation) = match &self.inner { + Inner::F64(st) => st.iterations_residual_violation(), + Inner::F32(st) => st.iterations_residual_violation(), + }; + let mut warnings = self.problem.warnings.clone(); + warnings.extend(self.notes.iter().cloned()); + SolveReport { + converged, + iterations, + residual: self.final_residual.unwrap_or(residual), + max_violation, + peak_bytes: self.tracker.peak(), + warnings, + } + } + + /// End of a single precision solve. For plain CG, free the work vectors + /// and compute the residual of the final `x` again in `f64`. Add a + /// warning if the solve did not converge. Returns the final `converged`. + /// A double precision solve is not changed. + fn finalize(&mut self, converged: bool) -> Result { + let Inner::F32(st) = &mut self.inner else { + return Ok(converged); + }; + let reason = st.stop_reason(); + let mut ok = converged; + let mut true_residual: Option = None; + // The work vectors are not needed any more. Free them first, so the + // peak of the check stays low. + st.release_work(&mut self.tracker); + #[cfg(test)] + if let Some(e) = self.inject_finalize_error.take() { + return Err(e); + } + if st.is_cg() { + let n = st.x().len(); + // One `f64` copy of `x` is needed for the check. If it does not + // fit, keep the residual of the iteration and say so. + match self.tracker.try_zeroed::(n) { + Ok(mut x64) => { + for (d, s) in x64.iter_mut().zip(st.x().iter()) { + *d = *s as f64; + } + // Free the `f32` copy during the check, then restore it. + // The `f32` to `f64` to `f32` trip is exact. + // The `f32` buffer cannot be reserved before the check: + // that would raise the peak by 4 bytes per dof. If the + // restore fails, `step` stores the error and the session + // stays failed. + let x32 = std::mem::take(st.x_mut()); + let bytes32 = (x32.len() as u64).saturating_mul(4); + drop(x32); + self.tracker.release(bytes32); + let res = self.problem.interpolator().residual_f64_compact(&x64); + let mut back: Vec = self.tracker.try_zeroed(n)?; + for (d, s) in back.iter_mut().zip(x64.iter()) { + *d = *s as f32; + } + self.tracker.release((n as u64).saturating_mul(8)); + drop(x64); + *st.x_mut() = back; + true_residual = Some(res); + } + Err(_) => { + self.notes.push( + "Single precision: there was no memory to check the residual in \ + double precision. The residual is the one of the iteration." + .into(), + ); + } + } + } + if let Some(res) = true_residual { + self.final_residual = Some(res); + ok = res <= self.tol; + } + if !ok { + let residual = self + .final_residual + .unwrap_or_else(|| st.iterations_residual_violation().1); + let mut text = format!( + "Single precision (f32) did not reach the tolerance {:e}. \ + The residual is {:e}. Use double precision or a larger tolerance.", + self.tol, residual + ); + if let Some(r) = reason { + text.push_str(&format!(" Stop reason: {r}.")); + } + self.notes.push(text); + } + Ok(ok) + } + + /// Run at most `max_iters` iterations. + pub fn step(&mut self, max_iters: usize) -> Result { + if let Some(e) = &self.failed { + return Err(e.clone()); + } + if let Some(converged) = self.done { + return Ok(Step::Done(self.report(converged))); + } + self.stage = Stage::Solve; + let interp = self.problem.interpolator(); + let out = match &mut self.inner { + Inner::F64(st) => st.step(interp, &mut self.tracker, max_iters)?, + Inner::F32(st) => st.step(interp, &mut self.tracker, max_iters)?, + }; + match out { + Out::Running { + check, + iteration, + residual, + max_violation, + } => { + if check { + self.stage = Stage::CheckConstraints; + } + Ok(Step::Running(Progress { + stage: self.stage.clone(), + iteration, + residual, + max_violation, + })) + } + Out::Done(converged) => { + let converged = match self.finalize(converged) { + Ok(c) => c, + Err(e) => { + self.failed = Some(e.clone()); + return Err(e); + } + }; + self.done = Some(converged); + Ok(Step::Done(self.report(converged))) + } + } + } + + /// Return the current field. `converged` is false until `Done`. + pub fn best_so_far(&self) -> Result<(Field, SolveReport), CoreError> { + if let Some(e) = &self.failed { + return Err(e.clone()); + } + let data = match &self.inner { + Inner::F64(st) => FieldData::F64(copy_vec(st.x())?), + Inner::F32(st) => FieldData::F32(copy_vec(st.x())?), + }; + let field = Field { + grid: self.problem.interpolator().grid, + data, + }; + Ok((field, self.report(self.done == Some(true)))) + } + + /// Move the field out. + pub fn finish(self) -> Result<(Field, SolveReport), CoreError> { + if let Some(e) = &self.failed { + return Err(e.clone()); + } + let report = self.report(self.done == Some(true)); + let grid = self.problem.interpolator().grid; + let data = match self.inner { + Inner::F64(st) => FieldData::F64(st.into_x()), + Inner::F32(st) => FieldData::F32(st.into_x()), + }; + Ok((Field { grid, data }, report)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn f32_session() -> SolveSession { + let mut p = Problem::new([0.0; 3], [5; 3], [1.0; 3]).unwrap(); + let pts = [1.0, 1.0, 1.0, 3.0, 2.0, 2.0, 2.0, 3.0, 1.0]; + p.add_value(&pts, &[1.0, 2.0, 3.0], &[1.0; 3]).unwrap(); + let s = Settings { + precision: Precision::F32, + budget_bytes: Some(1 << 24), + ..Settings::default() + }; + p.start(s).unwrap() + } + + #[test] + fn failed_finalize_is_final() { + let mut session = f32_session(); + let err = CoreError::OutOfMemory { requested_bytes: 1 }; + session.inject_finalize_error = Some(err.clone()); + let mut first = None; + for _ in 0..100_000 { + match session.step(1 << 20) { + Ok(Step::Running(_)) => continue, + other => { + first = Some(other); + break; + } + } + } + assert_eq!(first.unwrap().err(), Some(err.clone())); + // The second call returns the same error. It is not a done step. + assert_eq!(session.step(1 << 20).err(), Some(err)); + assert!(session.done.is_none()); + // No field of zero length leaves the failed session. + assert!(session.best_so_far().is_err()); + assert!(session.finish().is_err()); + } + + #[test] + fn f32_check_keeps_the_field_whole() { + let mut session = f32_session(); + loop { + if let Step::Done(_) = session.step(1 << 20).unwrap() { + break; + } + } + let (field, _) = session.finish().unwrap(); + assert_eq!(field.values_f64().len(), 125); + } +} diff --git a/packages/loop_interpolation_core/src/settings.rs b/packages/loop_interpolation_core/src/settings.rs new file mode 100644 index 00000000..55e79c95 --- /dev/null +++ b/packages/loop_interpolation_core/src/settings.rs @@ -0,0 +1,41 @@ +//! Solver settings. + +/// Floating-point type of the solver vectors. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Precision { + F64, + F32, +} + +/// Settings of a solve. +#[derive(Debug, Clone, PartialEq)] +pub struct Settings { + pub precision: Precision, + /// Relative residual tolerance. + pub tol: f64, + /// Total inner CG iterations. + pub max_iter: usize, + pub budget_bytes: Option, + /// ADMM penalty. + pub rho: f64, + pub inner_max_iter: usize, + pub inner_tol: f64, + pub admm_abs_tol: f64, + pub admm_rel_tol: f64, +} + +impl Default for Settings { + fn default() -> Self { + Self { + precision: Precision::F64, + tol: 1e-8, + max_iter: 5000, + budget_bytes: None, + rho: 1.0, + inner_max_iter: 3000, + inner_tol: 1e-6, + admm_abs_tol: 1e-4, + admm_rel_tol: 1e-3, + } + } +} diff --git a/packages/loop_interpolation_core/src/stencil.rs b/packages/loop_interpolation_core/src/stencil.rs new file mode 100644 index 00000000..e33b8b00 --- /dev/null +++ b/packages/loop_interpolation_core/src/stencil.rs @@ -0,0 +1,820 @@ +//! Matrix-free translation-invariant stencil operators. +//! +//! This is the Rust analogue of the Python `FiniteDifferenceInterpolator`'s +//! `_assemble_operator` / `matrix_free_regularisation_blocks` path, but +//! instead of gathering explicit column-index arrays (`neighbour_global_indexes`) +//! and storing them per family, a stencil here is just a short list of +//! `(offset, coefficient)` pairs applied directly against the dense 3D grid. +//! Nothing is ever materialised as a matrix: `forward`/`adjoint` are pure +//! functions of the field array, so the whole regularisation system costs +//! `O(dof)` time and `O(1)` extra memory beyond the field itself (plus +//! whatever scratch the caller supplies for the output). +//! +//! `adjoint` is implemented as a *correlation with the flipped stencil* +//! rather than a scatter-add: `adjoint(y)[c] = sum_offset coeff * y[c - +//! offset]`, treating a source row `c - offset` as contributing zero (rather +//! than reading `y` there) whenever that row is inactive (out of grid, or +//! within `margin` of a boundary) -- exactly mirroring which rows `forward` +//! zeroes out, so this is `A^T y` for any `y`, not just vectors already +//! produced by `forward`. No index collisions to resolve and no atomics +//! required, so both passes parallelise trivially over grid nodes. + +use crate::PAR_MIN_LEN; +use crate::grid::StructuredGrid3D; +use crate::par::*; +use crate::scalar::Scalar; + +#[derive(Debug, Clone)] +pub struct Stencil { + /// (di, dj, dk) neighbour offsets, paired index-for-index with `coeffs`. + pub offsets: Vec<(i32, i32, i32)>, + /// L2-normalised coefficients (unit-norm row, matching the Python + /// `add_constraints_to_least_squares` / `_store_matrix_free_regularisation_block` + /// row normalisation). + pub coeffs: Vec, + /// Per-axis margin (always 1, as in Python); a row is only evaluated at + /// nodes at least `margin[axis]` away from that axis's boundary, on all + /// three axes. + pub margin: [i32; 3], +} + +impl Stencil { + pub fn new(taps: &[((i32, i32, i32), f64)]) -> Self { + let norm = taps.iter().map(|(_, v)| v * v).sum::().sqrt(); + let offsets = taps.iter().map(|(o, _)| *o).collect::>(); + let coeffs = taps + .iter() + .map(|(_, v)| if norm > 0.0 { v / norm } else { *v }) + .collect::>(); + // Python puts each row only at nodes that are interior on ALL three + // axes (1 <= i, j, k <= n - 2), also for stencils that use no tap + // along an axis. So the margin is always 1 on every axis. + let margin = [1i32; 3]; + Self { + offsets, + coeffs, + margin, + } + } + + /// Whether the stencil's full footprint fits at node `(i, j, k)`, i.e. + /// whether this is a valid regularisation row there. `pub(crate)` so + /// `FiniteDifferenceInterpolator`'s fused six-family pass (see + /// `apply_normal_operator_into`) can reuse it directly instead of going + /// through `forward`/`adjoint` one family at a time. + #[inline] + pub(crate) fn is_row_active( + &self, + grid: &StructuredGrid3D, + i: usize, + j: usize, + k: usize, + ) -> bool { + let ijk = [i as i64, j as i64, k as i64]; + for (axis, coord) in ijk.into_iter().enumerate() { + let m = self.margin[axis] as i64; + let n = grid.nsteps[axis] as i64; + if coord < m || coord >= n - m { + return false; + } + } + true + } + + /// `out = A x` (overwrites `out`, no allocation). Rows too close to a + /// boundary for the full stencil footprint to fit are zero, matching + /// the Python "interior-only" regularisation rows. + pub fn forward_into(&self, grid: &StructuredGrid3D, x: &[S], out: &mut [S]) { + let nx = grid.nsteps[0]; + let ny = grid.nsteps[1]; + out.par_iter_mut() + .enumerate() + .with_min_len(PAR_MIN_LEN) + .for_each(|(flat, o)| { + let i = flat % nx; + let j = (flat / nx) % ny; + let k = flat / (nx * ny); + *o = if self.is_row_active(grid, i, j, k) { + // The sum runs in f64. Only the stored value is S. + S::from_f64( + self.offsets + .iter() + .zip(self.coeffs.iter()) + .map(|(d, c)| { + c * x[grid + .neighbour_index(i, j, k, *d) + .expect("row within margin")] + .to_f64() + }) + .sum::(), + ) + } else { + S::default() + }; + }); + } + + /// `y = A x`, allocating a fresh output vector. Convenience wrapper + /// around [`forward_into`]; prefer the `_into` form in a hot loop (see + /// `FiniteDifferenceInterpolator::solve`). + pub fn forward(&self, grid: &StructuredGrid3D, x: &[f64]) -> Vec { + let mut out = vec![0.0; grid.n_nodes()]; + self.forward_into(grid, x, &mut out); + out + } + + /// `out += scale * A^T y` (accumulates, no allocation), implemented as + /// correlation with the flipped stencil (see module docs) so it needs + /// no scatter-add and no atomics. + pub fn adjoint_accumulate_into( + &self, + grid: &StructuredGrid3D, + y: &[S], + scale: f64, + out: &mut [S], + ) { + if scale == 0.0 { + return; + } + let nx = grid.nsteps[0]; + let ny = grid.nsteps[1]; + out.par_iter_mut() + .enumerate() + .with_min_len(PAR_MIN_LEN) + .for_each(|(flat, o)| { + let i = flat % nx; + let j = (flat / nx) % ny; + let k = flat / (nx * ny); + let value: f64 = self + .offsets + .iter() + .zip(self.coeffs.iter()) + .map(|(d, c)| { + let flipped = (-d.0, -d.1, -d.2); + match grid.neighbour_index(i, j, k, flipped) { + // The source row `src` only actually contributes if + // it was an active row in `forward` (A[src, c] is + // defined to be 0 for inactive rows regardless of + // `y[src]`, since `y` here is an arbitrary covector, + // not necessarily `forward`'s output). + // + // `(si, sj, sk)` is just `(i, j, k) + flipped`, + // already validated in-bounds by `neighbour_index` + // above -- recover it with that same cheap add + // instead of decomposing `src` back via `node_ijk`'s + // two divisions + two modulos. + Some(src) => { + let si = (i as i64 + flipped.0 as i64) as usize; + let sj = (j as i64 + flipped.1 as i64) as usize; + let sk = (k as i64 + flipped.2 as i64) as usize; + if self.is_row_active(grid, si, sj, sk) { + c * y[src].to_f64() + } else { + 0.0 + } + } + None => 0.0, + } + }) + .sum(); + *o += S::from_f64(scale * value); + }); + } + + /// `out = A^T y`, allocating a fresh output vector. Convenience wrapper + /// around [`adjoint_accumulate_into`]. + pub fn adjoint(&self, grid: &StructuredGrid3D, y: &[f64]) -> Vec { + let mut out = vec![0.0; grid.n_nodes()]; + self.adjoint_accumulate_into(grid, y, 1.0, &mut out); + out + } + + /// `out += A^T (weight2 .* (A x))`, i.e. this stencil's contribution to + /// the normal-equations operator when the row weight varies *per node* + /// instead of being one uniform scalar (`weight2[c]` is squared-weight at + /// row/node `c`) -- the anisotropic/directional-regularisation analogue + /// of [`accumulate_normal_into`](Self::accumulate_normal_into), which + /// only supports a uniform scalar weight. `scratch` is reused as the + /// intermediate `A x` buffer (then overwritten in place with `weight2 .* + /// (A x)`), so this makes no allocations of its own -- same contract as + /// `accumulate_normal_into`. + pub fn accumulate_normal_weighted_into( + &self, + grid: &StructuredGrid3D, + x: &[f64], + weight2: &[f64], + scratch: &mut [f64], + out: &mut [f64], + ) { + self.forward_into(grid, x, scratch); + scratch + .par_iter_mut() + .zip(weight2.par_iter()) + .with_min_len(PAR_MIN_LEN) + .for_each(|(s, w2)| *s *= w2); + self.adjoint_accumulate_into(grid, scratch, 1.0, out); + } + + /// `diag(A^T diag(weight2) A)`, the per-node-weighted analogue of + /// [`diagonal`](Self::diagonal): mirrors its row-activity logic exactly, + /// but accumulates `weight2[src] * coeff^2` (the *row's* -- i.e. source + /// node's -- squared weight) instead of a uniform scalar times `coeff^2`. + pub fn diagonal_weighted(&self, grid: &StructuredGrid3D, weight2: &[f64]) -> Vec { + let nx = grid.nsteps[0]; + let ny = grid.nsteps[1]; + (0..grid.n_nodes()) + .into_par_iter() + .with_min_len(PAR_MIN_LEN) + .map(|flat| { + let i = flat % nx; + let j = (flat / nx) % ny; + let k = flat / (nx * ny); + self.offsets + .iter() + .zip(self.coeffs.iter()) + .map(|(d, c)| { + let flipped = (-d.0, -d.1, -d.2); + match grid.neighbour_index(i, j, k, flipped) { + Some(src) => { + let si = (i as i64 + flipped.0 as i64) as usize; + let sj = (j as i64 + flipped.1 as i64) as usize; + let sk = (k as i64 + flipped.2 as i64) as usize; + if self.is_row_active(grid, si, sj, sk) { + weight2[src] * c * c + } else { + 0.0 + } + } + None => 0.0, + } + }) + .sum() + }) + .collect() + } + + /// `diag(A^T A)`, used to build a Jacobi preconditioner without ever + /// forming `A`. Mirrors `adjoint`'s row-activity logic but accumulates + /// `coeff^2` instead of `coeff * y[src]`. Only called once per solve + /// (to build the preconditioner), so allocating here is fine. + pub fn diagonal(&self, grid: &StructuredGrid3D) -> Vec { + let nx = grid.nsteps[0]; + let ny = grid.nsteps[1]; + (0..grid.n_nodes()) + .into_par_iter() + .with_min_len(PAR_MIN_LEN) + .map(|flat| { + let i = flat % nx; + let j = (flat / nx) % ny; + let k = flat / (nx * ny); + self.offsets + .iter() + .zip(self.coeffs.iter()) + .map(|(d, c)| { + let flipped = (-d.0, -d.1, -d.2); + match grid.neighbour_index(i, j, k, flipped) { + Some(_) => { + let si = (i as i64 + flipped.0 as i64) as usize; + let sj = (j as i64 + flipped.1 as i64) as usize; + let sk = (k as i64 + flipped.2 as i64) as usize; + if self.is_row_active(grid, si, sj, sk) { + c * c + } else { + 0.0 + } + } + None => 0.0, + } + }) + .sum() + }) + .collect() + } + + /// `out += w^2 * A^T A x`, i.e. this stencil's contribution to the + /// weighted normal-equations operator. `scratch` is reused as the + /// intermediate `A x` buffer so this makes no allocations of its own, + /// which matters because it runs on every conjugate-gradient iteration. + pub fn accumulate_normal_into( + &self, + grid: &StructuredGrid3D, + x: &[f64], + weight: f64, + scratch: &mut [f64], + out: &mut [f64], + ) { + if weight == 0.0 { + return; + } + self.forward_into(grid, x, scratch); + self.adjoint_accumulate_into(grid, scratch, weight * weight, out); + } + + /// `out += w^2 * A^T A x`, allocating its own scratch buffer. Convenience + /// wrapper around [`accumulate_normal_into`] for callers outside the CG + /// hot loop (e.g. tests). + pub fn accumulate_normal( + &self, + grid: &StructuredGrid3D, + x: &[f64], + weight: f64, + out: &mut [f64], + ) { + let mut scratch = vec![0.0; grid.n_nodes()]; + self.accumulate_normal_into(grid, x, weight, &mut scratch, out); + } +} + +/// The six canonical second-derivative stencil families used for isotropic +/// curvature regularisation, mirroring `Operator.Dxx_mask` ... `Dyz_mask`. +pub fn dxx() -> Stencil { + Stencil::new(&[((-1, 0, 0), 1.0), ((0, 0, 0), -2.0), ((1, 0, 0), 1.0)]) +} + +pub fn dyy() -> Stencil { + Stencil::new(&[((0, -1, 0), 1.0), ((0, 0, 0), -2.0), ((0, 1, 0), 1.0)]) +} + +pub fn dzz() -> Stencil { + Stencil::new(&[((0, 0, -1), 1.0), ((0, 0, 0), -2.0), ((0, 0, 1), 1.0)]) +} + +pub fn dxy() -> Stencil { + Stencil::new(&[ + ((-1, -1, 0), 1.0), + ((1, -1, 0), -1.0), + ((-1, 1, 0), -1.0), + ((1, 1, 0), 1.0), + ]) +} + +pub fn dxz() -> Stencil { + Stencil::new(&[ + ((-1, 0, -1), 1.0), + ((1, 0, -1), -1.0), + ((-1, 0, 1), -1.0), + ((1, 0, 1), 1.0), + ]) +} + +pub fn dyz() -> Stencil { + Stencil::new(&[ + ((0, -1, -1), 1.0), + ((0, 1, -1), -1.0), + ((0, -1, 1), -1.0), + ((0, 1, 1), 1.0), + ]) +} + +/// A centred second-derivative stencil `[1, -2, 1]` active only on a fixed +/// axis-aligned "shell" layer (every node with `coord[axis] == layer`), rather than +/// everywhere-away-from-a-margin like [`Stencil`]. +/// +/// This is the Rust analogue of the Python `FiniteDifferenceInterpolator`'s +/// border regularisation terms (`assemble_borders`, built from +/// `Operator.Dxx_mask`/`Dyy_mask`/`Dzz_mask`, +/// applied at `_boundary_indexes`' single interior layer just inside +/// each face, on all nodes of that layer) -- structurally different from the six interior curvature +/// families, which is why it needs its own type instead of reusing +/// [`Stencil`]'s margin-based activation rule. +#[derive(Debug, Clone)] +pub struct EdgeStencil { + /// Which axis (0=x, 1=y, 2=z) the taps and the fixed layer are along. + pub axis: usize, + /// The fixed index along `axis` at which this stencil's rows live (every + /// node with `coord[axis] == layer`, for any index along the other two + /// axes). + pub layer: usize, + /// (signed step along `axis`, L2-normalised coefficient) taps. + pub taps: Vec<(i32, f64)>, +} + +impl EdgeStencil { + pub fn new(axis: usize, layer: usize, taps: &[(i32, f64)]) -> Self { + let norm = taps.iter().map(|(_, v)| v * v).sum::().sqrt(); + let taps = taps + .iter() + .map(|(o, v)| (*o, if norm > 0.0 { v / norm } else { *v })) + .collect(); + Self { axis, layer, taps } + } + + #[inline] + fn offset3(&self, o: i32) -> (i32, i32, i32) { + match self.axis { + 0 => (o, 0, 0), + 1 => (0, o, 0), + _ => (0, 0, o), + } + } + + #[inline] + fn is_row_active(&self, ijk: [usize; 3]) -> bool { + ijk[self.axis] == self.layer + } + + /// `(i, j, k)` of the `pos`-th node (`pos` in `0..layer_size(grid)`) on + /// the fixed layer `coord[axis] == layer_value`, in an arbitrary but + /// fixed row-major enumeration over the two non-`axis` dimensions. + #[inline] + fn ijk_at_layer( + &self, + nx: usize, + ny: usize, + layer_value: usize, + pos: usize, + ) -> (usize, usize, usize) { + match self.axis { + 0 => (layer_value, pos % ny, pos / ny), + 1 => (pos % nx, layer_value, pos / nx), + _ => (pos % nx, pos / nx, layer_value), + } + } + + /// Node count of one fixed-`axis` layer (`grid.n_nodes() / + /// grid.nsteps[axis]`) -- every position `forward_into` ever writes to, + /// and the size of one `target_layers` pass in + /// `adjoint_accumulate_into`/`diagonal`. + #[inline] + fn layer_size(&self, grid: &StructuredGrid3D) -> usize { + grid.n_nodes() / grid.nsteps[self.axis] + } + + /// The (at most `taps.len()`, deduplicated) axis-coordinates an output + /// node can ever receive a nonzero adjoint contribution at: `y[src]` is + /// only read (see `adjoint_accumulate_into`) when the *source* row, + /// reached by stepping `-off` from the output node, lands exactly on + /// `layer` -- i.e. `coord[axis] - off == layer`, so the output node's + /// own `coord[axis]` must be `layer + off` for some tap. Taps are always + /// single-step (`edge_forward`/`edge_backward` only ever emit `off` in + /// `{-1, 0, 1}`), so this is at most 3 distinct layers in practice, out + /// of `nsteps[axis]` possible ones -- the source of this type's whole + /// speedup over a naive full-`dof` scan. + fn target_layers(&self, n_axis: usize) -> Vec { + let mut layers: Vec = self + .taps + .iter() + .filter_map(|(off, _)| { + let t = self.layer as i64 + *off as i64; + (t >= 0 && (t as usize) < n_axis).then_some(t as usize) + }) + .collect(); + layers.sort_unstable(); + layers.dedup(); + layers + } + + /// `out = A x` (overwrites every row this stencil can ever produce; + /// every other row -- everywhere off this stencil's fixed `layer` -- is + /// left untouched rather than explicitly zeroed, since this stencil's + /// rows are zero there by construction and every caller either starts + /// from an already-zeroed buffer or (`accumulate_normal_into`) only ever + /// reads `out` back at positions this same call just wrote). Computes + /// each layer node's value in parallel into a small `layer_size`-length + /// buffer, then scatter-writes it into `out` serially (`layer_size` is + /// small enough -- one node layer, not the full grid -- that this beats + /// touching all `dof` positions to filter out the inactive ones, which + /// is what a naive full-grid pass would otherwise spend almost all its + /// time doing). + pub fn forward_into(&self, grid: &StructuredGrid3D, x: &[S], out: &mut [S]) { + let nx = grid.nsteps[0]; + let ny = grid.nsteps[1]; + let layer_size = self.layer_size(grid); + let values: Vec = (0..layer_size) + .into_par_iter() + .with_min_len(PAR_MIN_LEN) + .map(|pos| { + let (i, j, k) = self.ijk_at_layer(nx, ny, self.layer, pos); + S::from_f64( + self.taps + .iter() + .map(|(off, c)| { + c * x[grid + .neighbour_index(i, j, k, self.offset3(*off)) + .expect("edge stencil taps stay within the grid by construction")] + .to_f64() + }) + .sum::(), + ) + }) + .collect(); + for (pos, value) in values.into_iter().enumerate() { + let (i, j, k) = self.ijk_at_layer(nx, ny, self.layer, pos); + out[grid.index(i, j, k)] = value; + } + } + + pub fn forward(&self, grid: &StructuredGrid3D, x: &[f64]) -> Vec { + let mut out = vec![0.0; grid.n_nodes()]; + self.forward_into(grid, x, &mut out); + out + } + + /// `out += scale * A^T y`; see [`Stencil::adjoint_accumulate_into`] for + /// the flipped-stencil-correlation reasoning this mirrors, restricted + /// (see [`target_layers`](Self::target_layers)) to the handful of axis + /// layers that can ever receive a nonzero contribution instead of + /// scanning the full `dof`-length grid. + pub fn adjoint_accumulate_into( + &self, + grid: &StructuredGrid3D, + y: &[S], + scale: f64, + out: &mut [S], + ) { + if scale == 0.0 { + return; + } + let nx = grid.nsteps[0]; + let ny = grid.nsteps[1]; + let n_axis = grid.nsteps[self.axis]; + let layer_size = self.layer_size(grid); + for target_layer in self.target_layers(n_axis) { + let contributions: Vec = (0..layer_size) + .into_par_iter() + .with_min_len(PAR_MIN_LEN) + .map(|pos| { + let (i, j, k) = self.ijk_at_layer(nx, ny, target_layer, pos); + let ijk = [i, j, k]; + self.taps + .iter() + .map(|(off, c)| { + let flipped = self.offset3(-*off); + match grid.neighbour_index(i, j, k, flipped) { + Some(src) => { + let mut sijk = ijk; + sijk[self.axis] = + (ijk[self.axis] as i64 - *off as i64) as usize; + if self.is_row_active(sijk) { + c * y[src].to_f64() + } else { + 0.0 + } + } + None => 0.0, + } + }) + .sum() + }) + .collect(); + for (pos, value) in contributions.into_iter().enumerate() { + let (i, j, k) = self.ijk_at_layer(nx, ny, target_layer, pos); + out[grid.index(i, j, k)] += S::from_f64(scale * value); + } + } + } + + pub fn adjoint(&self, grid: &StructuredGrid3D, y: &[f64]) -> Vec { + let mut out = vec![0.0; grid.n_nodes()]; + self.adjoint_accumulate_into(grid, y, 1.0, &mut out); + out + } + + /// `diag(A^T A)`, restricted the same way as + /// [`adjoint_accumulate_into`](Self::adjoint_accumulate_into) (same + /// `is_row_active`-gated per-tap sum, just `c * c` instead of `c * + /// y[src]`). + pub fn diagonal(&self, grid: &StructuredGrid3D) -> Vec { + let nx = grid.nsteps[0]; + let ny = grid.nsteps[1]; + let n_axis = grid.nsteps[self.axis]; + let layer_size = self.layer_size(grid); + let mut diag = vec![0.0; grid.n_nodes()]; + for target_layer in self.target_layers(n_axis) { + let contributions: Vec = (0..layer_size) + .into_par_iter() + .with_min_len(PAR_MIN_LEN) + .map(|pos| { + let (i, j, k) = self.ijk_at_layer(nx, ny, target_layer, pos); + let ijk = [i, j, k]; + self.taps + .iter() + .map(|(off, c)| { + let flipped = self.offset3(-*off); + match grid.neighbour_index(i, j, k, flipped) { + Some(_) => { + let mut sijk = ijk; + sijk[self.axis] = + (ijk[self.axis] as i64 - *off as i64) as usize; + if self.is_row_active(sijk) { c * c } else { 0.0 } + } + None => 0.0, + } + }) + .sum() + }) + .collect(); + for (pos, value) in contributions.into_iter().enumerate() { + let (i, j, k) = self.ijk_at_layer(nx, ny, target_layer, pos); + diag[grid.index(i, j, k)] = value; + } + } + diag + } + + /// `out += w^2 * A^T A x`, using `scratch` as the intermediate `A x` + /// buffer (`forward_into`/`adjoint_accumulate_into` do each allocate a + /// small `layer_size`-length scratch `Vec` of their own -- see their + /// docs -- but nothing proportional to `dof`). + pub fn accumulate_normal_into( + &self, + grid: &StructuredGrid3D, + x: &[f64], + weight: f64, + scratch: &mut [f64], + out: &mut [f64], + ) { + if weight == 0.0 { + return; + } + self.forward_into(grid, x, scratch); + self.adjoint_accumulate_into(grid, scratch, weight * weight, out); + } + + /// `out += w^2 * A^T A x`, allocating its own scratch buffer. Convenience + /// wrapper around [`accumulate_normal_into`](Self::accumulate_normal_into) + /// for callers outside the CG hot loop (e.g. tests). + pub fn accumulate_normal( + &self, + grid: &StructuredGrid3D, + x: &[f64], + weight: f64, + out: &mut [f64], + ) { + let mut scratch = vec![0.0; grid.n_nodes()]; + self.accumulate_normal_into(grid, x, weight, &mut scratch, out); + } +} + +/// Border regularisation stencil for the layer just inside the *lower* face +/// along `axis` (index 1): centred second-derivative taps `[1, -2, 1]` at +/// offsets -1, 0, +1, matching the Python `Operator.Dxx_mask` (and y/z +/// equivalents) used by `assemble_borders`. Returns +/// `None` if `nsteps[axis] < 3`, matching the Python +/// `_boundary_indexes`/`assemble_borders`'s skip for axes too thin to have a +/// distinct "one layer in from the boundary" row. +pub fn edge_forward(axis: usize, nsteps: &[usize; 3]) -> Option { + if nsteps[axis] < 3 { + return None; + } + Some(EdgeStencil::new(axis, 1, &[(-1, 1.0), (0, -2.0), (1, 1.0)])) +} + +/// Border regularisation stencil for the layer just inside the *upper* face +/// along `axis` (index `nsteps[axis] - 2`): the same centred `[1, -2, 1]` +/// taps as [`edge_forward`], matching the Python upper-face border rows. +/// `None` under the same `nsteps[axis] < 3` condition as [`edge_forward`]. +pub fn edge_backward(axis: usize, nsteps: &[usize; 3]) -> Option { + if nsteps[axis] < 3 { + return None; + } + Some(EdgeStencil::new( + axis, + nsteps[axis] - 2, + &[(-1, 1.0), (0, -2.0), (1, 1.0)], + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn grid() -> StructuredGrid3D { + StructuredGrid3D::new([0.0, 0.0, 0.0], [5, 5, 5], [1.0, 1.0, 1.0]) + } + + #[test] + fn f32_forward_matches_f64_forward() { + let grid = grid(); + let n = grid.n_nodes(); + let x64: Vec = (0..n).map(|i| ((i * 37 + 11) % 97) as f64 / 97.0).collect(); + let x32: Vec = x64.iter().map(|v| *v as f32).collect(); + let stencil = dxx(); + let y64 = stencil.forward(&grid, &x64); + let mut y32 = vec![0.0f32; n]; + stencil.forward_into(&grid, &x32, &mut y32); + let scale = y64.iter().fold(0.0f64, |m, v| m.max(v.abs())).max(1e-12); + for (a, b) in y64.iter().zip(y32.iter()) { + // The error is relative to the largest value of the field. + assert!((a - *b as f64).abs() / scale < 1e-6); + } + } + + #[test] + fn dxx_zero_on_linear_field() { + let grid = grid(); + // f(i,j,k) = i (a linear function of x), dxx should vanish everywhere. + let x: Vec = (0..grid.n_nodes()) + .map(|flat| grid.node_ijk(flat).0 as f64) + .collect(); + let y = dxx().forward(&grid, &x); + assert!(y.iter().all(|v| v.abs() < 1e-12)); + } + + #[test] + fn dxx_nonzero_on_quadratic_field() { + let grid = grid(); + let x: Vec = (0..grid.n_nodes()) + .map(|flat| { + let i = grid.node_ijk(flat).0 as f64; + i * i + }) + .collect(); + let y = dxx().forward(&grid, &x); + // interior nodes (i in [1, nx-2]) should be nonzero + let (nx, _, _) = (grid.nsteps[0], grid.nsteps[1], grid.nsteps[2]); + let mid = grid.index(nx / 2, 2, 2); + assert!(y[mid].abs() > 1e-6); + } + + #[test] + fn adjoint_matches_transpose_dot_product() { + // == for a random-ish field. + let grid = grid(); + let n = grid.n_nodes(); + let x: Vec = (0..n).map(|i| ((i * 37 + 11) % 97) as f64 / 97.0).collect(); + let y: Vec = (0..n).map(|i| ((i * 53 + 7) % 89) as f64 / 89.0).collect(); + + for stencil in [dxx(), dyy(), dzz(), dxy(), dxz(), dyz()] { + let ax = stencil.forward(&grid, &x); + let aty = stencil.adjoint(&grid, &y); + let lhs: f64 = ax.iter().zip(y.iter()).map(|(a, b)| a * b).sum(); + let rhs: f64 = x.iter().zip(aty.iter()).map(|(a, b)| a * b).sum(); + assert!((lhs - rhs).abs() < 1e-9, "lhs={lhs} rhs={rhs}"); + } + } + + fn edge_stencils(nsteps: &[usize; 3]) -> Vec { + let mut v = Vec::new(); + for axis in 0..3 { + if let Some(s) = edge_forward(axis, nsteps) { + v.push(s); + } + if let Some(s) = edge_backward(axis, nsteps) { + v.push(s); + } + } + v + } + + #[test] + fn edge_stencil_adjoint_matches_transpose_dot_product() { + let grid = grid(); + let n = grid.n_nodes(); + let x: Vec = (0..n).map(|i| ((i * 37 + 11) % 97) as f64 / 97.0).collect(); + let y: Vec = (0..n).map(|i| ((i * 53 + 7) % 89) as f64 / 89.0).collect(); + + for stencil in edge_stencils(&grid.nsteps) { + let ax = stencil.forward(&grid, &x); + let aty = stencil.adjoint(&grid, &y); + let lhs: f64 = ax.iter().zip(y.iter()).map(|(a, b)| a * b).sum(); + let rhs: f64 = x.iter().zip(aty.iter()).map(|(a, b)| a * b).sum(); + assert!( + (lhs - rhs).abs() < 1e-9, + "axis={} layer={} lhs={lhs} rhs={rhs}", + stencil.axis, + stencil.layer + ); + } + } + + #[test] + fn edge_stencil_only_active_on_its_fixed_layer() { + let grid = grid(); + let n = grid.n_nodes(); + let x: Vec = (0..n).map(|i| ((i * 13 + 3) % 71) as f64 / 71.0).collect(); + + for stencil in edge_stencils(&grid.nsteps) { + let y = stencil.forward(&grid, &x); + #[allow(clippy::needless_range_loop)] + for flat in 0..n { + let (i, j, k) = grid.node_ijk(flat); + let ijk = [i, j, k][stencil.axis]; + if ijk != stencil.layer { + assert!(y[flat].abs() < 1e-12, "expected zero row away from layer"); + } + } + // At least one row on the layer should be nonzero for a + // non-constant field. + assert!(y.iter().any(|v| v.abs() > 1e-9)); + } + } + + #[test] + fn edge_stencil_zero_on_constant_field() { + let grid = grid(); + let x = vec![1.0; grid.n_nodes()]; + for stencil in edge_stencils(&grid.nsteps) { + let y = stencil.forward(&grid, &x); + assert!(y.iter().all(|v| v.abs() < 1e-12)); + } + } + + #[test] + fn edge_stencil_none_for_thin_axis() { + let nsteps = [2, 5, 5]; + assert!(edge_forward(0, &nsteps).is_none()); + assert!(edge_backward(0, &nsteps).is_none()); + assert!(edge_forward(1, &nsteps).is_some()); + } +} diff --git a/packages/loop_interpolation_core/src/wasm.rs b/packages/loop_interpolation_core/src/wasm.rs new file mode 100644 index 00000000..81c1c51e --- /dev/null +++ b/packages/loop_interpolation_core/src/wasm.rs @@ -0,0 +1,603 @@ +//! WebAssembly bindings (wasm-bindgen). Compiled only with the feature `wasm`. +//! +//! # JavaScript signatures (used by `web/worker.js`) +//! +//! ```text +//! memoryBytes() -> number // size of the wasm memory in bytes +//! +//! new Problem(origin: Float64Array(3), counts: Uint32Array(3), step: Float64Array(3)) +//! problem.setRegularisation(w: object) // keys, all optional: dxx dyy dzz dxy dxz dyz dx dy dz +//! // (missing key = 1.0; dx/dy/dz are the border weights) +//! problem.setRidgeFactor(ridgeFactor: number) // 0 removes the ridge term; default 1e-8 +//! problem.setStart(x0: Float64Array(nodes)) // start value; default 0 +//! problem.addValue(points: Float64Array(3n), values: Float64Array(n), weights: Float64Array(n)) +//! problem.addGradient(points, vectors: Float64Array(3n), weights) +//! problem.addNormal(points, vectors, weights) +//! problem.addTangent(points, vectors, weights) +//! problem.addInterface(points, ids: Float64Array(n), weight: number) +//! problem.addValueBound(points, lower, upper, weights) +//! problem.addPairBound(pointsA, pointsB, lower, upper, weights) +//! problem.estimate(settings: object|undefined) -> Estimate { total, vectors, constraints, working } +//! problem.start(settings: object|undefined) -> Session // consumes the problem +//! +//! settings keys, all optional: +//! precision: 'f64' | 'f32', tol, maxIter, budgetBytes, rho, +//! innerMaxIter, innerTol, admmAbsTol, admmRelTol +//! +//! session.step(maxIters: number) -> StepResult +//! { done: boolean, stage: 'setup'|'solve'|'check'|'done', iteration: number, +//! residual: number, violation: number, +//! converged: boolean, peakBytes: number, warnings: string[] } +//! (converged, peakBytes and warnings are valid when done is true. +//! After done, step keeps returning done.) +//! session.field() -> Float64Array | Float32Array // one copy out +//! session.evaluate(points: Float64Array(3m)) -> Float64Array(m) +//! session.evaluateGradient(points: Float64Array(3m)) -> Float64Array(3m) +//! session.free() // drops the session +//! ``` +//! +//! # Errors +//! +//! Every method that can fail throws a `JsError`. Its `message` is JSON text +//! with a `kind` key, for example +//! `{"kind":"OverBudget","estimate":123,"budget":100,"maxNodesPerAxis":40}`. +//! The kinds are `InvalidInput` (`constraint`, `reason`), `GridTooSmall` +//! (`axis`, `nodes`), `GridTooLarge` (`nodes`, `maxNodes`), `NoConstraints`, +//! `OverBudget`, `OutOfMemory` (`requestedBytes`), `Infeasible` (`worst`: list +//! of `{index, kind, violation}`), `PointOutsideGrid` (`index`), +//! `Unsupported` (`setting`) and `SolveRunning`. A non-finite number is `null`. + +use wasm_bindgen::prelude::*; + +use crate::error::{BoundKind, CoreError}; +use crate::field::{Field, FieldData}; +use crate::interpolator::{BorderRegularisationWeights, RegularisationWeights}; +use crate::problem::Problem; +use crate::report::{SolveReport, Stage, Step}; +use crate::session::SolveSession; +use crate::settings::{Precision, Settings}; + +// --------------------------------------------------------------------------- +// JSON text for errors (no serde) +// --------------------------------------------------------------------------- + +/// Write `s` as a JSON string with quotes and escapes. +fn json_str(out: &mut String, s: &str) { + out.push('"'); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)), + c => out.push(c), + } + } + out.push('"'); +} + +/// Write a number as JSON. A non-finite number is `null`. +fn json_num(out: &mut String, x: f64) { + if x.is_finite() { + out.push_str(&format!("{x}")); + } else { + out.push_str("null"); + } +} + +/// Write `,"key":` (the key is a fixed ASCII name). +fn json_key(out: &mut String, key: &str) { + out.push_str(",\""); + out.push_str(key); + out.push_str("\":"); +} + +/// Make the JSON text of an error. +fn error_json(e: &CoreError) -> String { + let mut o = String::from("{\"kind\":"); + match e { + CoreError::InvalidInput { constraint, reason } => { + json_str(&mut o, "InvalidInput"); + json_key(&mut o, "constraint"); + json_str(&mut o, constraint); + json_key(&mut o, "reason"); + json_str(&mut o, reason); + } + CoreError::GridTooSmall { axis, nodes } => { + json_str(&mut o, "GridTooSmall"); + json_key(&mut o, "axis"); + json_num(&mut o, *axis as f64); + json_key(&mut o, "nodes"); + json_num(&mut o, *nodes as f64); + } + CoreError::GridTooLarge { nodes, max_nodes } => { + json_str(&mut o, "GridTooLarge"); + json_key(&mut o, "nodes"); + json_num(&mut o, *nodes as f64); + json_key(&mut o, "maxNodes"); + json_num(&mut o, *max_nodes as f64); + } + CoreError::NoConstraints => json_str(&mut o, "NoConstraints"), + CoreError::OverBudget { + estimate_bytes, + budget_bytes, + max_nodes_per_axis, + } => { + json_str(&mut o, "OverBudget"); + json_key(&mut o, "estimate"); + json_num(&mut o, *estimate_bytes as f64); + json_key(&mut o, "budget"); + json_num(&mut o, *budget_bytes as f64); + json_key(&mut o, "maxNodesPerAxis"); + json_num(&mut o, *max_nodes_per_axis as f64); + } + CoreError::OutOfMemory { requested_bytes } => { + json_str(&mut o, "OutOfMemory"); + json_key(&mut o, "requestedBytes"); + json_num(&mut o, *requested_bytes as f64); + } + CoreError::Infeasible { worst } => { + json_str(&mut o, "Infeasible"); + json_key(&mut o, "worst"); + o.push('['); + for (i, w) in worst.iter().enumerate() { + if i > 0 { + o.push(','); + } + o.push_str("{\"index\":"); + json_num(&mut o, w.index as f64); + json_key(&mut o, "kind"); + json_str( + &mut o, + match w.kind { + BoundKind::Value => "value", + BoundKind::Pair => "pair", + }, + ); + json_key(&mut o, "violation"); + json_num(&mut o, w.violation); + o.push('}'); + } + o.push(']'); + } + CoreError::PointOutsideGrid { index } => { + json_str(&mut o, "PointOutsideGrid"); + json_key(&mut o, "index"); + json_num(&mut o, *index as f64); + } + CoreError::Unsupported { setting } => { + json_str(&mut o, "Unsupported"); + json_key(&mut o, "setting"); + json_str(&mut o, setting); + } + CoreError::SolveRunning => json_str(&mut o, "SolveRunning"), + } + o.push('}'); + o +} + +/// Make a thrown JavaScript error from a core error. +fn js_err(e: CoreError) -> JsError { + JsError::new(&error_json(&e)) +} + +/// Make a thrown error for a wrong argument of the binding. +fn arg_err(constraint: &str, reason: &str) -> JsError { + js_err(CoreError::InvalidInput { + constraint: constraint.into(), + reason: reason.into(), + }) +} + +// --------------------------------------------------------------------------- +// Plain JavaScript objects (settings and weights), read with getters +// --------------------------------------------------------------------------- + +#[wasm_bindgen] +extern "C" { + /// A plain JavaScript object with the settings keys. + pub type SettingsObj; + #[wasm_bindgen(method, getter)] + fn precision(this: &SettingsObj) -> Option; + #[wasm_bindgen(method, getter)] + fn tol(this: &SettingsObj) -> Option; + #[wasm_bindgen(method, getter, js_name = maxIter)] + fn max_iter(this: &SettingsObj) -> Option; + #[wasm_bindgen(method, getter, js_name = budgetBytes)] + fn budget_bytes(this: &SettingsObj) -> Option; + #[wasm_bindgen(method, getter)] + fn rho(this: &SettingsObj) -> Option; + #[wasm_bindgen(method, getter, js_name = innerMaxIter)] + fn inner_max_iter(this: &SettingsObj) -> Option; + #[wasm_bindgen(method, getter, js_name = innerTol)] + fn inner_tol(this: &SettingsObj) -> Option; + #[wasm_bindgen(method, getter, js_name = admmAbsTol)] + fn admm_abs_tol(this: &SettingsObj) -> Option; + #[wasm_bindgen(method, getter, js_name = admmRelTol)] + fn admm_rel_tol(this: &SettingsObj) -> Option; + + /// A plain JavaScript object with the regularisation weight keys. + pub type WeightsObj; + #[wasm_bindgen(method, getter)] + fn dxx(this: &WeightsObj) -> Option; + #[wasm_bindgen(method, getter)] + fn dyy(this: &WeightsObj) -> Option; + #[wasm_bindgen(method, getter)] + fn dzz(this: &WeightsObj) -> Option; + #[wasm_bindgen(method, getter)] + fn dxy(this: &WeightsObj) -> Option; + #[wasm_bindgen(method, getter)] + fn dxz(this: &WeightsObj) -> Option; + #[wasm_bindgen(method, getter)] + fn dyz(this: &WeightsObj) -> Option; + #[wasm_bindgen(method, getter)] + fn dx(this: &WeightsObj) -> Option; + #[wasm_bindgen(method, getter)] + fn dy(this: &WeightsObj) -> Option; + #[wasm_bindgen(method, getter)] + fn dz(this: &WeightsObj) -> Option; +} + +/// Convert a JavaScript number to a count. Reject values that are not whole. +fn to_count(name: &str, x: f64) -> Result { + if x.is_finite() && x >= 0.0 && x.fract() == 0.0 && x <= 9.0e15 { + Ok(x as usize) + } else { + Err(arg_err(name, "must be a whole number that is 0 or more")) + } +} + +/// Read the settings from a JavaScript object. `undefined` gives the defaults. +fn parse_settings(obj: Option) -> Result { + let mut s = Settings::default(); + let Some(o) = obj else { + return Ok(s); + }; + if let Some(p) = o.precision() { + s.precision = match p.as_str() { + "f64" => Precision::F64, + "f32" => Precision::F32, + _ => return Err(arg_err("settings.precision", "must be 'f64' or 'f32'")), + }; + } + if let Some(v) = o.tol() { + s.tol = v; + } + if let Some(v) = o.max_iter() { + s.max_iter = to_count("settings.maxIter", v)?; + } + if let Some(v) = o.budget_bytes() { + s.budget_bytes = Some(to_count("settings.budgetBytes", v)? as u64); + } + if let Some(v) = o.rho() { + s.rho = v; + } + if let Some(v) = o.inner_max_iter() { + s.inner_max_iter = to_count("settings.innerMaxIter", v)?; + } + if let Some(v) = o.inner_tol() { + s.inner_tol = v; + } + if let Some(v) = o.admm_abs_tol() { + s.admm_abs_tol = v; + } + if let Some(v) = o.admm_rel_tol() { + s.admm_rel_tol = v; + } + Ok(s) +} + +/// Convert a typed array of 3 values to an array. +fn triple(name: &str, v: &[f64]) -> Result<[f64; 3], JsError> { + <[f64; 3]>::try_from(v).map_err(|_| arg_err(name, "must have 3 values")) +} + +// --------------------------------------------------------------------------- +// Exported types +// --------------------------------------------------------------------------- + +/// Memory estimate, in bytes. All values are JavaScript numbers. +#[wasm_bindgen(js_name = Estimate)] +pub struct WasmEstimate { + pub total: f64, + pub vectors: f64, + pub constraints: f64, + pub working: f64, +} + +/// Result of one `step` call. +/// Only the `String` and `Vec` fields need a clone in their getter. +#[wasm_bindgen(js_name = StepResult)] +pub struct WasmStepResult { + pub done: bool, + #[wasm_bindgen(getter_with_clone)] + pub stage: String, + pub iteration: f64, + pub residual: f64, + pub violation: f64, + pub converged: bool, + #[wasm_bindgen(js_name = peakBytes)] + pub peak_bytes: f64, + #[wasm_bindgen(getter_with_clone)] + pub warnings: Vec, +} + +/// An interpolation problem (JavaScript name `Problem`). +#[wasm_bindgen(js_name = Problem)] +pub struct WasmProblem { + inner: Problem, +} + +#[wasm_bindgen(js_class = Problem)] +impl WasmProblem { + #[wasm_bindgen(constructor)] + pub fn new(origin: &[f64], counts: &[u32], step: &[f64]) -> Result { + let origin = triple("origin", origin)?; + let step = triple("step", step)?; + let counts = <[u32; 3]>::try_from(counts) + .map_err(|_| arg_err("counts", "must have 3 values"))? + .map(|c| c as usize); + let inner = Problem::new(origin, counts, step).map_err(js_err)?; + Ok(WasmProblem { inner }) + } + + #[wasm_bindgen(js_name = setRegularisation)] + pub fn set_regularisation(&mut self, weights: WeightsObj) { + let d = RegularisationWeights::default(); + self.inner.set_regularisation(RegularisationWeights { + dxx: weights.dxx().unwrap_or(d.dxx), + dyy: weights.dyy().unwrap_or(d.dyy), + dzz: weights.dzz().unwrap_or(d.dzz), + dxy: weights.dxy().unwrap_or(d.dxy), + dxz: weights.dxz().unwrap_or(d.dxz), + dyz: weights.dyz().unwrap_or(d.dyz), + }); + let (dx, dy, dz) = (weights.dx(), weights.dy(), weights.dz()); + if dx.is_some() || dy.is_some() || dz.is_some() { + let b = BorderRegularisationWeights::default(); + self.inner + .set_border_regularisation(BorderRegularisationWeights { + dx: dx.unwrap_or(b.dx), + dy: dy.unwrap_or(b.dy), + dz: dz.unwrap_or(b.dz), + }); + } + } + + #[wasm_bindgen(js_name = setRidgeFactor)] + pub fn set_ridge_factor(&mut self, ridge_factor: f64) -> Result<(), JsError> { + self.inner.set_ridge_factor(ridge_factor).map_err(js_err) + } + + #[wasm_bindgen(js_name = setStart)] + pub fn set_start(&mut self, x0: &[f64]) -> Result<(), JsError> { + self.inner.set_start(x0).map_err(js_err) + } + + #[wasm_bindgen(js_name = addValue)] + pub fn add_value( + &mut self, + points: &[f64], + values: &[f64], + weights: &[f64], + ) -> Result<(), JsError> { + self.inner + .add_value(points, values, weights) + .map_err(js_err) + } + + #[wasm_bindgen(js_name = addGradient)] + pub fn add_gradient( + &mut self, + points: &[f64], + vectors: &[f64], + weights: &[f64], + ) -> Result<(), JsError> { + self.inner + .add_gradient(points, vectors, weights) + .map_err(js_err) + } + + #[wasm_bindgen(js_name = addNormal)] + pub fn add_normal( + &mut self, + points: &[f64], + vectors: &[f64], + weights: &[f64], + ) -> Result<(), JsError> { + self.inner + .add_normal(points, vectors, weights) + .map_err(js_err) + } + + #[wasm_bindgen(js_name = addTangent)] + pub fn add_tangent( + &mut self, + points: &[f64], + vectors: &[f64], + weights: &[f64], + ) -> Result<(), JsError> { + self.inner + .add_tangent(points, vectors, weights) + .map_err(js_err) + } + + #[wasm_bindgen(js_name = addInterface)] + pub fn add_interface( + &mut self, + points: &[f64], + ids: &[f64], + weight: f64, + ) -> Result<(), JsError> { + self.inner + .add_interface(points, ids, weight) + .map_err(js_err) + } + + #[wasm_bindgen(js_name = addValueBound)] + pub fn add_value_bound( + &mut self, + points: &[f64], + lower: &[f64], + upper: &[f64], + weights: &[f64], + ) -> Result<(), JsError> { + self.inner + .add_value_bound(points, lower, upper, weights) + .map_err(js_err) + } + + #[wasm_bindgen(js_name = addPairBound)] + pub fn add_pair_bound( + &mut self, + points_a: &[f64], + points_b: &[f64], + lower: &[f64], + upper: &[f64], + weights: &[f64], + ) -> Result<(), JsError> { + self.inner + .add_pair_bound(points_a, points_b, lower, upper, weights) + .map_err(js_err) + } + + pub fn estimate(&self, settings: Option) -> Result { + let s = parse_settings(settings)?; + let e = self.inner.estimate_memory(&s).map_err(js_err)?; + Ok(WasmEstimate { + total: e.total as f64, + vectors: e.vectors as f64, + constraints: e.constraints as f64, + working: e.working as f64, + }) + } + + /// Start a solve. This consumes the problem. + pub fn start(self, settings: Option) -> Result { + let s = parse_settings(settings)?; + let session = self.inner.start(s).map_err(js_err)?; + Ok(WasmSession { + session: Some(session), + field: None, + report: None, + }) + } +} + +/// A running solve (JavaScript name `Session`). +#[wasm_bindgen(js_name = Session)] +pub struct WasmSession { + /// `Some` until the solve is done. + session: Option, + /// `Some` after the solve is done. + field: Option, + /// `Some` after the solve is done. + report: Option, +} + +/// Make the step result for a finished solve. +fn done_result(r: &SolveReport) -> WasmStepResult { + WasmStepResult { + done: true, + stage: "done".into(), + iteration: r.iterations as f64, + residual: r.residual, + violation: r.max_violation, + converged: r.converged, + peak_bytes: r.peak_bytes as f64, + warnings: r.warnings.clone(), + } +} + +impl WasmSession { + /// Run `f` on the field. Before the end of the solve, the field is the + /// best result so far. + fn with_field(&self, f: impl FnOnce(&Field) -> Result) -> Result { + if let Some(field) = &self.field { + return f(field).map_err(js_err); + } + match &self.session { + Some(s) => { + let (field, _) = s.best_so_far().map_err(js_err)?; + f(&field).map_err(js_err) + } + None => Err(arg_err("session", "the session has no field")), + } + } +} + +#[wasm_bindgen(js_class = Session)] +impl WasmSession { + /// Run at most `max_iters` iterations. + pub fn step(&mut self, max_iters: f64) -> Result { + if let Some(report) = &self.report { + return Ok(done_result(report)); + } + let max_iters = to_count("maxIters", max_iters)?; + let Some(session) = self.session.as_mut() else { + return Err(arg_err("session", "the session has no field")); + }; + match session.step(max_iters).map_err(js_err)? { + Step::Running(p) => Ok(WasmStepResult { + done: false, + stage: match p.stage { + Stage::Setup => "setup", + Stage::Solve => "solve", + Stage::CheckConstraints => "check", + } + .into(), + iteration: p.iteration as f64, + residual: p.residual, + violation: p.max_violation, + converged: false, + peak_bytes: 0.0, + warnings: Vec::new(), + }), + Step::Done(report) => { + // Keep the field. `finish` moves it out of the session. + if let Some(session) = self.session.take() { + let (field, _) = session.finish().map_err(js_err)?; + self.field = Some(field); + } + let result = done_result(&report); + self.report = Some(report); + Ok(result) + } + } + } + + /// Copy the field out: `Float64Array` or `Float32Array`. + pub fn field(&self) -> Result { + self.with_field(|field| { + Ok(match &field.data { + FieldData::F64(v) => JsValue::from(v.clone()), + FieldData::F32(v) => JsValue::from(v.clone()), + }) + }) + } + + pub fn evaluate(&self, points: &[f64]) -> Result, JsError> { + self.with_field(|f| f.evaluate(points)) + } + + #[wasm_bindgen(js_name = evaluateGradient)] + pub fn evaluate_gradient(&self, points: &[f64]) -> Result, JsError> { + self.with_field(|f| f.evaluate_gradient(points)) + } +} + +/// Size of the wasm linear memory in bytes. It is 0 on other targets. +/// The memory never shrinks, so after a solve this is the peak. +#[wasm_bindgen(js_name = memoryBytes)] +pub fn memory_bytes() -> f64 { + #[cfg(target_arch = "wasm32")] + { + (core::arch::wasm32::memory_size::<0>() * 65536) as f64 + } + #[cfg(not(target_arch = "wasm32"))] + { + 0.0 + } +} diff --git a/packages/loop_interpolation_core/tests/class_stencil.rs b/packages/loop_interpolation_core/tests/class_stencil.rs new file mode 100644 index 00000000..ef538693 --- /dev/null +++ b/packages/loop_interpolation_core/tests/class_stencil.rs @@ -0,0 +1,168 @@ +//! The per-class stencil (the default operator on grids with 7 or more +//! nodes on every axis) must equal the unfused reference operator to about +//! 1e-15, on random vectors and on several grid shapes. + +use loop_interpolation_core::{ + BorderRegularisationWeights, FiniteDifferenceInterpolator, RegularisationWeights, + StructuredGrid3D, +}; + +/// Small deterministic generator (LCG). It returns a number in [-0.5, 0.5). +struct Lcg(u64); + +impl Lcg { + fn next(&mut self) -> f64 { + self.0 = self + .0 + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + ((self.0 >> 11) as f64) / ((1u64 << 53) as f64) - 0.5 + } +} + +fn interpolator( + shape: [usize; 3], + weights: RegularisationWeights, + border: BorderRegularisationWeights, + ridge: f64, +) -> FiniteDifferenceInterpolator { + let grid = StructuredGrid3D::new([0.0; 3], shape, [1.0; 3]); + let mut interp = FiniteDifferenceInterpolator::new(grid) + .with_regularisation(weights) + .with_border_regularisation(border); + interp.ridge_factor = ridge; + // Data rows at a corner, on a face and inside, so the data term is + // checked with the sweep. + interp.add_value_constraint([0.0, 0.0, 0.0], 1.0, 2.0); + interp.add_value_constraint([2.5, 1.5, 3.0], -0.4, 1.0); + interp.add_gradient_constraint([1.0, 2.0, 0.5], [1.0, 0.3, -0.2], 1.5); + interp +} + +/// Largest difference, relative to the largest entry of the reference. +fn relative_difference(a: &[f64], b: &[f64]) -> f64 { + let scale = b.iter().fold(0.0f64, |m, v| m.max(v.abs())).max(1e-300); + let diff = a + .iter() + .zip(b.iter()) + .fold(0.0f64, |m, (x, y)| m.max((x - y).abs())); + diff / scale +} + +fn check(shape: [usize; 3], weights: RegularisationWeights, border: BorderRegularisationWeights) { + for ridge in [0.0, 1e-8, 0.3] { + let interp = interpolator(shape, weights, border, ridge); + // The class path needs no scratch. The fused path does. + let class_path = shape.iter().all(|&n| n >= 7); + assert_eq!(interp.scratch_len() == 0, class_path, "shape {shape:?}"); + let mut rng = Lcg(shape[0] as u64 * 131 + shape[1] as u64 * 17 + shape[2] as u64); + for _ in 0..3 { + let x: Vec = (0..interp.dof()).map(|_| rng.next()).collect(); + let class = interp.apply_normal_operator(&x); + let reference = interp.apply_normal_operator_unfused(&x); + let rel = relative_difference(&class, &reference); + println!("shape {shape:?} ridge {ridge}: relative difference {rel:e}"); + assert!( + rel <= 2e-15, + "shape {shape:?} ridge {ridge} weights {weights:?} border {border:?}: \ + relative difference {rel:e}" + ); + } + } +} + +const SHAPES: [[usize; 3]; 9] = [ + [7, 7, 7], + [7, 8, 9], + [9, 7, 16], + [16, 16, 16], + [12, 10, 7], + [8, 15, 11], + [30, 9, 7], + [61, 61, 31], + [40, 33, 7], +]; + +#[test] +fn class_stencil_equals_the_reference_with_default_weights() { + for shape in SHAPES { + check( + shape, + RegularisationWeights::default(), + BorderRegularisationWeights::default(), + ); + } +} + +#[test] +fn class_stencil_equals_the_reference_with_other_weights() { + let weights = RegularisationWeights { + dxx: 2.0, + dyy: 0.5, + dzz: 1.5, + dxy: 0.0, + dxz: 3.0, + dyz: 0.25, + }; + let border = BorderRegularisationWeights { + dx: 0.0, + dy: 2.0, + dz: 0.7, + }; + for shape in SHAPES { + check(shape, weights, border); + } +} + +#[test] +fn class_stencil_equals_the_reference_without_curvature_or_border() { + let no_curvature = RegularisationWeights { + dxx: 0.0, + dyy: 0.0, + dzz: 0.0, + dxy: 0.0, + dxz: 0.0, + dyz: 0.0, + }; + let no_border = BorderRegularisationWeights { + dx: 0.0, + dy: 0.0, + dz: 0.0, + }; + for shape in [[7, 7, 7], [11, 8, 13]] { + check(shape, no_curvature, BorderRegularisationWeights::default()); + check(shape, RegularisationWeights::default(), no_border); + } +} + +/// Below 7 nodes on an axis the operator keeps the fused path. It must +/// still equal the reference. +#[test] +fn small_grids_keep_the_fused_path() { + for shape in [[6, 9, 9], [9, 5, 9], [4, 4, 4], [6, 6, 6]] { + check( + shape, + RegularisationWeights::default(), + BorderRegularisationWeights::default(), + ); + } +} + +/// The class path is symmetric: = . +#[test] +fn class_stencil_is_symmetric() { + let interp = interpolator( + [13, 9, 11], + RegularisationWeights::default(), + BorderRegularisationWeights::default(), + 1e-8, + ); + let mut rng = Lcg(7); + let x: Vec = (0..interp.dof()).map(|_| rng.next()).collect(); + let y: Vec = (0..interp.dof()).map(|_| rng.next()).collect(); + let nx = interp.apply_normal_operator(&x); + let ny = interp.apply_normal_operator(&y); + let a: f64 = nx.iter().zip(y.iter()).map(|(p, q)| p * q).sum(); + let b: f64 = x.iter().zip(ny.iter()).map(|(p, q)| p * q).sum(); + assert!((a - b).abs() <= 1e-12 * a.abs().max(b.abs()), "{a} vs {b}"); +} diff --git a/packages/loop_interpolation_core/tests/common/mod.rs b/packages/loop_interpolation_core/tests/common/mod.rs new file mode 100644 index 00000000..6f1c4513 --- /dev/null +++ b/packages/loop_interpolation_core/tests/common/mod.rs @@ -0,0 +1,75 @@ +//! Test helper: a counting allocator. +//! It wraps the system allocator and tracks the bytes now in use (CURRENT) +//! and the highest value of CURRENT since the last reset (PEAK). +#![allow(dead_code)] + +use std::alloc::{GlobalAlloc, Layout, System}; +use std::sync::atomic::{AtomicUsize, Ordering}; + +pub struct CountingAllocator; + +static CURRENT: AtomicUsize = AtomicUsize::new(0); +static PEAK: AtomicUsize = AtomicUsize::new(0); + +fn add(bytes: usize) { + let now = CURRENT.fetch_add(bytes, Ordering::SeqCst) + bytes; + PEAK.fetch_max(now, Ordering::SeqCst); +} + +fn sub(bytes: usize) { + CURRENT.fetch_sub(bytes, Ordering::SeqCst); +} + +unsafe impl GlobalAlloc for CountingAllocator { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + let p = unsafe { System.alloc(layout) }; + if !p.is_null() { + add(layout.size()); + } + p + } + + unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 { + let p = unsafe { System.alloc_zeroed(layout) }; + if !p.is_null() { + add(layout.size()); + } + p + } + + unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { + unsafe { System.dealloc(ptr, layout) }; + sub(layout.size()); + } + + unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 { + let p = unsafe { System.realloc(ptr, layout, new_size) }; + if !p.is_null() { + // On success the old block is gone and the new block exists. + if new_size >= layout.size() { + add(new_size - layout.size()); + } else { + sub(layout.size() - new_size); + } + } + p + } +} + +#[global_allocator] +static ALLOC: CountingAllocator = CountingAllocator; + +/// Set PEAK to the bytes now in use. +pub fn reset_peak() { + PEAK.store(CURRENT.load(Ordering::SeqCst), Ordering::SeqCst); +} + +/// Bytes now in use. +pub fn current() -> usize { + CURRENT.load(Ordering::SeqCst) +} + +/// Highest bytes in use since the last reset. +pub fn peak() -> usize { + PEAK.load(Ordering::SeqCst) +} diff --git a/packages/loop_interpolation_core/tests/constraint_families.rs b/packages/loop_interpolation_core/tests/constraint_families.rs new file mode 100644 index 00000000..a3b363cb --- /dev/null +++ b/packages/loop_interpolation_core/tests/constraint_families.rs @@ -0,0 +1,213 @@ +//! Tests of the constraint families on a solved field (T024). +//! They check: tangent and interface properties, point evaluation, a large +//! grid origin, and the thread count. +//! Some tests use `Field::evaluate` and `Field::evaluate_gradient`. +//! These tests fail until those functions are implemented (T025). + +use loop_interpolation_core::{CoreError, Field, Problem, Settings}; + +const N: usize = 12; + +/// Settings with a tight tolerance, so that the solve error is small. +fn settings() -> Settings { + Settings { + tol: 1e-12, + max_iter: 5000, + ..Settings::default() + } +} + +/// Add `shift` to each coordinate of a flat array of points. +fn shifted(points: &[f64], shift: f64) -> Vec { + points.iter().map(|v| v + shift).collect() +} + +/// Points for the value constraints: two planes, z = 2 and z = 9. +fn value_data() -> (Vec, Vec, Vec) { + let xy = [(2.0, 2.0), (8.0, 2.0), (2.0, 8.0), (8.0, 8.0), (5.0, 5.0)]; + let mut p = vec![]; + let mut v = vec![]; + for &(x, y) in &xy { + p.extend_from_slice(&[x, y, 2.0]); + v.push(0.0); + p.extend_from_slice(&[x, y, 9.0]); + v.push(7.0); + } + let w = vec![1.0; v.len()]; + (p, v, w) +} + +/// Points of one interface (same id). They are at z = 5.5. +fn interface_data() -> (Vec, Vec) { + let p = vec![ + 3.0, 3.0, 5.5, // + 7.0, 3.0, 5.5, // + 3.0, 7.0, 5.5, // + 7.0, 7.0, 5.5, + ]; + let ids = vec![1.0; 4]; + (p, ids) +} + +/// Points and unit tangent vectors (direction x). +fn tangent_data() -> (Vec, Vec, Vec) { + let p = vec![4.0, 4.0, 4.0, 6.0, 6.0, 7.0, 5.0, 3.0, 6.0]; + let t = vec![1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0]; + let w = vec![1.0; 3]; + (p, t, w) +} + +/// Build and solve a problem. All coordinates move by `shift`. +fn solve_shifted(shift: f64) -> Field { + let mut p = Problem::new([shift; 3], [N, N, N], [1.0; 3]).unwrap(); + let (vp, vv, vw) = value_data(); + p.add_value(&shifted(&vp, shift), &vv, &vw).unwrap(); + let (ip, ids) = interface_data(); + p.add_interface(&shifted(&ip, shift), &ids, 1.0).unwrap(); + let (tp, tv, tw) = tangent_data(); + p.add_tangent(&shifted(&tp, shift), &tv, &tw).unwrap(); + let (field, _report) = p.solve(settings()).unwrap(); + field +} + +fn range_of(values: &[f64]) -> f64 { + let lo = values.iter().cloned().fold(f64::INFINITY, f64::min); + let hi = values.iter().cloned().fold(f64::NEG_INFINITY, f64::max); + hi - lo +} + +fn max_abs_diff(a: &[f64], b: &[f64]) -> f64 { + assert_eq!(a.len(), b.len()); + a.iter() + .zip(b) + .map(|(x, y)| (x - y).abs()) + .fold(0.0, f64::max) +} + +/// Scenario 5: tangent vectors and interface pairs. +#[test] +fn tangent_and_interface_properties() { + let field = solve_shifted(0.0); + let range = range_of(&field.values_f64()); + + // The gradient is normal to each tangent vector. + let (tp, tv, _) = tangent_data(); + let grad = field.evaluate_gradient(&tp).unwrap(); + for i in 0..tp.len() / 3 { + let g = &grad[3 * i..3 * i + 3]; + let t = &tv[3 * i..3 * i + 3]; + let len = (g[0] * g[0] + g[1] * g[1] + g[2] * g[2]).sqrt(); + let dot = g[0] * t[0] + g[1] * t[1] + g[2] * t[2]; + assert!(len > 0.0, "gradient length is zero at tangent point {i}"); + assert!( + (dot / len).abs() < 1e-3, + "tangent {i}: dot / |g| = {}", + dot / len + ); + } + + // The interface points have the same field value. + let (ip, _) = interface_data(); + let vals = field.evaluate(&ip).unwrap(); + let spread = range_of(&vals); + assert!( + spread <= 1e-4 * range, + "interface spread {spread} is more than 1e-4 of the range {range}" + ); +} + +/// Scenario 8: point evaluation. +#[test] +fn evaluate_matches_nodes_and_gradient() { + let field = solve_shifted(0.0); + let values = field.values_f64(); + let g = &field.grid; + + // Evaluation at the nodes gives the node values. + let mut pts = Vec::with_capacity(3 * values.len()); + for flat in 0..g.n_nodes() { + let (i, j, k) = g.node_ijk(flat); + pts.push(g.origin[0] + i as f64 * g.step_vector[0]); + pts.push(g.origin[1] + j as f64 * g.step_vector[1]); + pts.push(g.origin[2] + k as f64 * g.step_vector[2]); + } + let at_nodes = field.evaluate(&pts).unwrap(); + let range = range_of(&values); + assert!(max_abs_diff(&at_nodes, &values) <= 1e-9 * range.max(1.0)); + + // The gradient equals central differences of the field. + let h = 1e-3; + let probes = [ + [3.37, 4.21, 5.43], + [6.61, 2.77, 8.19], + [9.13, 7.49, 3.31], + [2.29, 9.83, 6.57], + ]; + let mut probe_flat = vec![]; + for p in &probes { + probe_flat.extend_from_slice(p); + } + let grad = field.evaluate_gradient(&probe_flat).unwrap(); + let mut fd = vec![0.0; grad.len()]; + for (n, p) in probes.iter().enumerate() { + for axis in 0..3 { + let mut hi = *p; + let mut lo = *p; + hi[axis] += h; + lo[axis] -= h; + let fh = field.evaluate(&hi).unwrap()[0]; + let fl = field.evaluate(&lo).unwrap()[0]; + fd[3 * n + axis] = (fh - fl) / (2.0 * h); + } + } + let largest = grad.iter().cloned().map(f64::abs).fold(0.0, f64::max); + assert!( + max_abs_diff(&grad, &fd) <= 1e-3 * largest, + "gradient and central differences differ" + ); + + // A point outside the grid gives an error with the point index. + let outside = [5.0, 5.0, 5.0, -1.0, 5.0, 5.0]; + assert_eq!( + field.evaluate(&outside).unwrap_err(), + CoreError::PointOutsideGrid { index: 1 } + ); + assert_eq!( + field.evaluate_gradient(&outside).unwrap_err(), + CoreError::PointOutsideGrid { index: 1 } + ); +} + +/// Scenario 9: a large grid origin gives the same field. +#[test] +fn large_origin_gives_same_field() { + let base = solve_shifted(0.0).values_f64(); + let moved = solve_shifted(5_000_000.0).values_f64(); + let range = range_of(&base); + assert!( + max_abs_diff(&base, &moved) <= 1e-6 * range, + "the shifted field differs from the base field" + ); +} + +/// Scenario 7: the thread count does not change the field. +#[cfg(feature = "parallel")] +#[test] +fn thread_count_does_not_change_field() { + use rayon::ThreadPoolBuilder; + + let run = |threads: usize| -> Vec { + let pool = ThreadPoolBuilder::new() + .num_threads(threads) + .build() + .unwrap(); + pool.install(|| solve_shifted(0.0).values_f64()) + }; + let one = run(1); + let four = run(4); + let range = range_of(&one); + assert!( + max_abs_diff(&one, &four) <= 1e-6 * range, + "fields from 1 and 4 threads differ" + ); +} diff --git a/packages/loop_interpolation_core/tests/edge_cases.rs b/packages/loop_interpolation_core/tests/edge_cases.rs new file mode 100644 index 00000000..7f2bfd02 --- /dev/null +++ b/packages/loop_interpolation_core/tests/edge_cases.rs @@ -0,0 +1,444 @@ +//! Adversarial / edge-case coverage for `loop_interpolation_core`. +//! +//! Unlike the inline `#[cfg(test)]` modules in `src/*.rs` (which exercise +//! the happy path: normal-operator symmetry, affine-field recovery, +//! stencil/CG correctness), this file targets degenerate or malformed +//! inputs: single-node grid axes, zero/negative regularisation weights, +//! NaN/Inf constraint and query data, and extreme step-vector anisotropy. +//! The goal throughout is "does this panic / silently corrupt state", not +//! "is this numerically ideal" -- several of these document existing, +//! acceptable behaviour (e.g. NaN propagating into a solve) rather than +//! asserting an idealised response. +//! +//! This is a Cargo *integration* test: it only uses the crate's public API +//! (`loop_interpolation_core::{...}`), deliberately kept disjoint from the +//! new constraint-type work landing concurrently on another +//! branch in `src/grid.rs` / `src/stencil.rs` / `src/interpolator.rs`. + +use loop_interpolation_core::interpolator::BorderRegularisationWeights; +use loop_interpolation_core::stencil::{dxx, dxy, dxz, dyy, dyz, dzz}; +use loop_interpolation_core::{ + FiniteDifferenceInterpolator, RegularisationWeights, StructuredGrid3D, +}; + +fn all_finite(v: &[f64]) -> bool { + v.iter().all(|x| x.is_finite()) +} + +// --------------------------------------------------------------------- +// Degenerate grids: a `1` in one or more `nsteps` axes. +// --------------------------------------------------------------------- + +#[test] +fn trilinear_coeffs_none_for_single_node_axis_x() { + let grid = StructuredGrid3D::new([0.0, 0.0, 0.0], [1, 5, 5], [1.0, 1.0, 1.0]); + // Even a point that is otherwise perfectly "inside" the y/z extent + // must come back None because the x axis has < 2 nodes. + assert!(grid.trilinear_coeffs([0.0, 2.0, 2.0]).is_none()); + assert!(grid.trilinear_coeffs([0.0, 0.0, 0.0]).is_none()); +} + +#[test] +fn trilinear_coeffs_none_for_single_node_axis_y_and_z() { + let grid_y = StructuredGrid3D::new([0.0, 0.0, 0.0], [5, 1, 5], [1.0, 1.0, 1.0]); + assert!(grid_y.trilinear_coeffs([2.0, 0.0, 2.0]).is_none()); + + let grid_z = StructuredGrid3D::new([0.0, 0.0, 0.0], [5, 5, 1], [1.0, 1.0, 1.0]); + assert!(grid_z.trilinear_coeffs([2.0, 2.0, 0.0]).is_none()); +} + +#[test] +fn trilinear_coeffs_none_for_fully_degenerate_grid() { + let grid = StructuredGrid3D::new([0.0, 0.0, 0.0], [1, 1, 1], [1.0, 1.0, 1.0]); + assert_eq!(grid.n_nodes(), 1); + assert!(grid.trilinear_coeffs([0.0, 0.0, 0.0]).is_none()); +} + +#[test] +fn interpolator_on_single_node_axis_grid_does_not_panic() { + // nsteps = [1, 5, 5]: every point is rejected by trilinear_coeffs (the + // degenerate axis short-circuits before the bounding-box check), so + // constraints are silently dropped -- same contract as out-of-bounds + // points -- and solve()/evaluate() must still run cleanly on the + // resulting (constraint-free) system. + let grid = StructuredGrid3D::new([0.0, 0.0, 0.0], [1, 5, 5], [1.0, 1.0, 1.0]); + let mut interp = FiniteDifferenceInterpolator::new(grid); + interp.add_value_constraint([0.0, 2.0, 2.0], 1.0, 5.0); + assert_eq!(interp.n_value_constraints(), 0); + + assert_eq!(interp.dof(), 5 * 5); + let diag = interp.diagonal(); + assert_eq!(diag.len(), interp.dof()); + assert!(all_finite(&diag)); + + let x = vec![0.0; interp.dof()]; + let n_x = interp.apply_normal_operator(&x); + assert!(all_finite(&n_x)); + + let result = interp.solve(50, 1e-10); + assert!( + result.converged, + "expected trivial all-zero-rhs system to converge immediately" + ); + assert!(all_finite(&result.x)); + + let evaluated = interp.evaluate(&result.x, &[[0.0, 2.0, 2.0]]); + // Query point is rejected for the same reason value constraints are. + assert!(evaluated[0].is_nan()); +} + +#[test] +fn interpolator_on_fully_degenerate_grid_does_not_panic() { + let grid = StructuredGrid3D::new([0.0, 0.0, 0.0], [1, 1, 1], [1.0, 1.0, 1.0]); + let mut interp = FiniteDifferenceInterpolator::new(grid); + interp.add_value_constraint([0.0, 0.0, 0.0], 3.0, 1.0); + assert_eq!(interp.n_value_constraints(), 0); + assert_eq!(interp.dof(), 1); + + let result = interp.solve(10, 1e-10); + assert!(result.converged); + assert_eq!(result.x.len(), 1); + assert!(result.x[0].is_finite()); +} + +// --------------------------------------------------------------------- +// Zero / negative regularisation weights. +// --------------------------------------------------------------------- + +fn make_grid_9() -> StructuredGrid3D { + StructuredGrid3D::new([0.0, 0.0, 0.0], [9, 9, 9], [1.0, 1.0, 1.0]) +} + +fn sample_field(n: usize, seed: u64) -> Vec { + (0..n) + .map(|i| (((i as u64).wrapping_mul(seed).wrapping_add(11)) % 97) as f64 / 97.0) + .collect() +} + +#[test] +fn zero_regularisation_weights_drop_stencil_contribution() { + // With every family weight at 0.0, only the data term + ridge damping + // should contribute to the normal operator/diagonal. + let grid = make_grid_9(); + let zero_weights = RegularisationWeights { + dxx: 0.0, + dyy: 0.0, + dzz: 0.0, + dxy: 0.0, + dxz: 0.0, + dyz: 0.0, + }; + // Border (centred second-derivative) regularisation was added by a later P0 pass + // and defaults to weight 1.0, always on -- zero it out too, since this + // test is specifically about the six curvature families contributing + // nothing, not about border regularisation. + let mut interp = FiniteDifferenceInterpolator::new(grid) + .with_regularisation(zero_weights) + .with_border_regularisation(BorderRegularisationWeights { + dx: 0.0, + dy: 0.0, + dz: 0.0, + }); + interp.add_value_constraint([4.0, 4.0, 4.0], 1.0, 1.0); + + let x = sample_field(interp.dof(), 31); + let n_x = interp.apply_normal_operator(&x); + assert!(all_finite(&n_x)); + + // Reference: data-only contribution + ridge, computed by hand from the + // same public API (ridge_factor defaults to 1e-8). + let ridge2 = interp.ridge_factor * interp.ridge_factor; + let expected: Vec = x.iter().map(|xi| ridge2 * xi).collect(); + // n_x should equal ridge term plus the (small, localised) data-term + // contribution -- rather than re-derive that by hand, just check that + // away from the single constrained node's 8-corner footprint, n_x is + // exactly the ridge term (i.e. no stray curvature contribution leaks + // in when all six weights are zero). + let grid = interp.grid; + let touched: std::collections::HashSet = grid + .trilinear_coeffs([4.0, 4.0, 4.0]) + .unwrap() + .0 + .iter() + .copied() + .collect(); + for (idx, (&nxi, &exp)) in n_x.iter().zip(expected.iter()).enumerate() { + if !touched.contains(&idx) { + assert!((nxi - exp).abs() < 1e-12, "node {idx}: {nxi} vs {exp}"); + } + } +} + +#[test] +fn negative_regularisation_weights_match_positive_counterpart() { + // Weights are always squared before use, so -w must behave identically + // to +w in every observable: apply_normal_operator, diagonal, and the + // solved field. + let grid = make_grid_9(); + let positive = RegularisationWeights { + dxx: 2.0, + dyy: 1.5, + dzz: 0.7, + dxy: 1.0, + dxz: 1.0, + dyz: 1.0, + }; + let negative = RegularisationWeights { + dxx: -2.0, + dyy: -1.5, + dzz: -0.7, + dxy: -1.0, + dxz: -1.0, + dyz: -1.0, + }; + + let build = |weights: RegularisationWeights| { + let mut interp = + FiniteDifferenceInterpolator::new(make_grid_9()).with_regularisation(weights); + interp.add_value_constraint([2.0, 2.0, 2.0], 1.0, 3.0); + interp.add_value_constraint([6.0, 3.0, 4.0], -0.5, 2.0); + interp + }; + let _ = grid; // grid var only used to anchor dof() below + + let interp_pos = build(positive); + let interp_neg = build(negative); + + let x = sample_field(interp_pos.dof(), 17); + let n_pos = interp_pos.apply_normal_operator(&x); + let n_neg = interp_neg.apply_normal_operator(&x); + for (a, b) in n_pos.iter().zip(n_neg.iter()) { + assert!((a - b).abs() < 1e-9, "{a} vs {b}"); + } + + let diag_pos = interp_pos.diagonal(); + let diag_neg = interp_neg.diagonal(); + for (a, b) in diag_pos.iter().zip(diag_neg.iter()) { + assert!((a - b).abs() < 1e-9, "{a} vs {b}"); + } + + let solved_pos = interp_pos.solve(500, 1e-10); + let solved_neg = interp_neg.solve(500, 1e-10); + assert!(solved_pos.converged && solved_neg.converged); + for (a, b) in solved_pos.x.iter().zip(solved_neg.x.iter()) { + assert!((a - b).abs() < 1e-6, "{a} vs {b}"); + } +} + +#[test] +fn negative_ridge_factor_matches_positive_counterpart() { + // ridge_factor is squared the same way regularisation weights are. + let mut pos = FiniteDifferenceInterpolator::new(make_grid_9()); + pos.ridge_factor = 0.05; + pos.add_value_constraint([4.0, 4.0, 4.0], 2.0, 1.0); + + let mut neg = FiniteDifferenceInterpolator::new(make_grid_9()); + neg.ridge_factor = -0.05; + neg.add_value_constraint([4.0, 4.0, 4.0], 2.0, 1.0); + + let x = sample_field(pos.dof(), 5); + let n_pos = pos.apply_normal_operator(&x); + let n_neg = neg.apply_normal_operator(&x); + for (a, b) in n_pos.iter().zip(n_neg.iter()) { + assert!((a - b).abs() < 1e-9); + } +} + +#[test] +fn zero_weight_value_constraint_contributes_nothing() { + let grid = make_grid_9(); + let mut with_extra_zero_weight = FiniteDifferenceInterpolator::new(grid); + with_extra_zero_weight.add_value_constraint([2.0, 2.0, 2.0], 1.0, 4.0); + with_extra_zero_weight.add_value_constraint([6.0, 6.0, 6.0], 999.0, 0.0); // zero-weight: should be a no-op + + let baseline_grid = make_grid_9(); + let mut baseline = FiniteDifferenceInterpolator::new(baseline_grid); + baseline.add_value_constraint([2.0, 2.0, 2.0], 1.0, 4.0); + + // The zero-weight constraint is still recorded... + assert_eq!(with_extra_zero_weight.n_value_constraints(), 2); + assert_eq!(baseline.n_value_constraints(), 1); + + // ...but has no effect on diagonal(), rhs(), or the normal operator. + let diag_a = with_extra_zero_weight.diagonal(); + let diag_b = baseline.diagonal(); + for (a, b) in diag_a.iter().zip(diag_b.iter()) { + assert!((a - b).abs() < 1e-15, "{a} vs {b}"); + } + + let rhs_a = with_extra_zero_weight.rhs(); + let rhs_b = baseline.rhs(); + for (a, b) in rhs_a.iter().zip(rhs_b.iter()) { + assert!((a - b).abs() < 1e-15, "{a} vs {b}"); + } + + let x = sample_field(with_extra_zero_weight.dof(), 41); + let n_a = with_extra_zero_weight.apply_normal_operator(&x); + let n_b = baseline.apply_normal_operator(&x); + for (a, b) in n_a.iter().zip(n_b.iter()) { + assert!((a - b).abs() < 1e-15, "{a} vs {b}"); + } +} + +// --------------------------------------------------------------------- +// NaN / Inf inputs. +// --------------------------------------------------------------------- + +#[test] +fn nan_value_propagates_into_rhs_but_not_diagonal() { + // Documents current behaviour: a NaN *value* is not rejected the way + // an out-of-bounds *point* is -- it flows straight into rhs() (and + // from there into solve()'s residual), while diagonal() (which never + // reads `.value`) stays finite. This is a known gap, not a guarantee. + let mut interp = FiniteDifferenceInterpolator::new(make_grid_9()); + interp.add_value_constraint([4.0, 4.0, 4.0], f64::NAN, 1.0); + assert_eq!(interp.n_value_constraints(), 1); + + let diag = interp.diagonal(); + assert!( + all_finite(&diag), + "diagonal() must not be poisoned by a NaN constraint value" + ); + + let rhs = interp.rhs(); + assert!( + rhs.iter().any(|v| v.is_nan()), + "NaN constraint value should surface in rhs()" + ); + + // NaN in one rhs entry poisons the global dot-products CG relies on, + // so the solved field ends up NaN too -- this is the actual, currently + // unguarded behaviour, asserted here rather than "fixed" silently. + let result = interp.solve(20, 1e-10); + assert!(result.x.iter().any(|v| v.is_nan())); +} + +#[test] +fn infinite_weight_produces_infinite_diagonal_entry() { + // Documents current behaviour: weight is squared and used directly, so + // an infinite weight yields an infinite diagonal entry rather than + // being rejected the way a zero-norm row already is. + let mut interp = FiniteDifferenceInterpolator::new(make_grid_9()); + interp.add_value_constraint([4.0, 4.0, 4.0], 1.0, f64::INFINITY); + assert_eq!(interp.n_value_constraints(), 1); + + let diag = interp.diagonal(); + assert!(diag.iter().any(|v| v.is_infinite())); +} + +#[test] +fn nan_query_point_is_now_dropped_like_out_of_bounds() { + // Before the fix in `StructuredGrid3D::trilinear_coeffs`, a NaN + // coordinate slipped past the bounds check (every comparison against + // NaN is false) and was then silently clamped to 0.0 by `f64::clamp`, + // i.e. treated as if the point sat exactly at the grid's lower corner + // -- a real point silently substituted for a garbage one. The bounds + // check now uses the "reject unless provably inside" form, which + // recovers `None` for NaN the same way it already did for +-inf. + let grid = StructuredGrid3D::new([0.0, 0.0, 0.0], [9, 9, 9], [1.0, 1.0, 1.0]); + assert!(grid.trilinear_coeffs([f64::NAN, 2.0, 2.0]).is_none()); + assert!(grid.trilinear_coeffs([2.0, f64::NAN, 2.0]).is_none()); + assert!(grid.trilinear_coeffs([2.0, 2.0, f64::NAN]).is_none()); + + // +-inf were already rejected pre-fix; assert they still are. + assert!(grid.trilinear_coeffs([f64::INFINITY, 2.0, 2.0]).is_none()); + assert!( + grid.trilinear_coeffs([f64::NEG_INFINITY, 2.0, 2.0]) + .is_none() + ); + + let mut interp = FiniteDifferenceInterpolator::new(grid); + interp.add_value_constraint([f64::NAN, 2.0, 2.0], 1.0, 1.0); + assert_eq!( + interp.n_value_constraints(), + 0, + "NaN-coordinate constraint should be dropped, not silently relocated" + ); + + let result = interp.solve(10, 1e-10); + let evaluated = interp.evaluate( + &result.x, + &[[f64::NAN, 0.0, 0.0], [f64::INFINITY, 0.0, 0.0]], + ); + assert!(evaluated[0].is_nan()); + assert!(evaluated[1].is_nan()); +} + +// --------------------------------------------------------------------- +// Extremely anisotropic step vectors. +// --------------------------------------------------------------------- + +#[test] +fn trilinear_coeffs_finite_and_sum_to_one_under_extreme_anisotropy() { + let grid = StructuredGrid3D::new([0.0, 0.0, 0.0], [5, 5, 5], [1e-6, 1.0, 1e6]); + // Interior, non-node-aligned point well within each axis's extent + // (x extent = 4e-6, y extent = 4.0, z extent = 4e6). + let point = [1.5e-6, 2.5, 1.5e6]; + let (idx, coefs) = grid + .trilinear_coeffs(point) + .expect("point is within bounds"); + assert!(all_finite(&coefs)); + let sum: f64 = coefs.iter().sum(); + assert!((sum - 1.0).abs() < 1e-9, "sum={sum}"); + assert!(idx.iter().all(|&i| i < grid.n_nodes())); +} + +#[test] +fn curvature_stencils_stay_finite_under_extreme_anisotropy() { + // The stencils operate purely on grid-index space (they don't read + // step_vector at all), so this mostly documents that fact -- an + // extreme step_vector cannot itself introduce overflow/underflow into + // forward/adjoint, since no division by step size ever happens there. + let grid = StructuredGrid3D::new([0.0, 0.0, 0.0], [7, 7, 7], [1e-6, 1.0, 1e6]); + let x: Vec = (0..grid.n_nodes()) + .map(|flat| { + let (i, j, k) = grid.node_ijk(flat); + (i * i + j + 3 * k) as f64 + }) + .collect(); + + for stencil in [dxx(), dyy(), dzz(), dxy(), dxz(), dyz()] { + let y = stencil.forward(&grid, &x); + assert!(all_finite(&y), "stencil forward produced non-finite output"); + let adj = stencil.adjoint(&grid, &y); + assert!( + all_finite(&adj), + "stencil adjoint produced non-finite output" + ); + } +} + +#[test] +fn solve_stays_finite_under_extreme_anisotropy() { + let grid = StructuredGrid3D::new([0.0, 0.0, 0.0], [5, 5, 5], [1e-6, 1.0, 1e6]); + let mut interp = FiniteDifferenceInterpolator::new(grid); + + // Scatter a handful of value constraints across the (physically tiny + // in x, huge in z) domain. + let samples = [ + ([0.5e-6, 0.5, 0.5e6], 1.0), + ([3.5e-6, 3.5, 3.5e6], 2.0), + ([1.0e-6, 3.0, 2.0e6], -1.0), + ([2.0e-6, 1.0, 0.5e6], 0.5), + ]; + for (p, v) in samples { + interp.add_value_constraint(p, v, 10.0); + } + assert_eq!(interp.n_value_constraints(), samples.len()); + + let diag = interp.diagonal(); + assert!( + all_finite(&diag), + "diagonal() must stay finite under extreme step-vector anisotropy" + ); + + let result = interp.solve(2000, 1e-8); + assert!( + all_finite(&result.x), + "solve() produced non-finite node values under extreme anisotropy" + ); + + let query = [[2.0e-6, 2.0, 2.0e6]]; + let predicted = interp.evaluate(&result.x, &query); + assert!(all_finite(&predicted)); +} diff --git a/packages/loop_interpolation_core/tests/input_validation.rs b/packages/loop_interpolation_core/tests/input_validation.rs new file mode 100644 index 00000000..2989d9eb --- /dev/null +++ b/packages/loop_interpolation_core/tests/input_validation.rs @@ -0,0 +1,205 @@ +//! Input validation tests for `Problem` (FR-018, story 1 scenario 10). +//! +//! Each test builds a `Problem` and calls one `add_*` method with bad input. +//! The test checks the error variant and that `constraint` names the family +//! and the row, for example `value[3]`. +//! +//! A rotation cannot be given. The API has no rotation argument. The grid +//! has an origin, node counts and a step only. So no test for rotation +//! exists. + +use loop_interpolation_core::{CoreError, Problem, Settings}; + +/// Make a valid 5 x 5 x 5 grid with step 1 and origin 0. +fn problem() -> Problem { + Problem::new([0.0; 3], [5, 5, 5], [1.0; 3]).expect("valid grid") +} + +/// Make `n` points inside the grid. Each point has 3 values. +fn pts(n: usize) -> Vec { + (0..n) + .flat_map(|i| [1.0 + (i % 3) as f64, 1.5, 1.5]) + .collect() +} + +/// Check that the error is `InvalidInput` and that `constraint` has the +/// family name at the start and the row in brackets. +fn assert_invalid(r: Result<(), CoreError>, family: &str, row: usize) { + match r { + Err(CoreError::InvalidInput { constraint, .. }) => { + let tag = format!("[{row}]"); + assert!( + constraint.starts_with(family) && constraint.contains(&tag), + "constraint was {constraint:?}, expected {family}{tag}" + ); + } + other => panic!("expected InvalidInput, got {other:?}"), + } +} + +/// True if one warning text contains the text `needle`. +fn has_warning(p: &Problem, needle: &str) -> bool { + p.warnings().iter().any(|w| w.contains(needle)) +} + +#[test] +fn nan_in_point() { + let mut p = problem(); + let mut x = pts(5); + x[3 * 3 + 1] = f64::NAN; // row 3 + let r = p.add_value(&x, &[0.0; 5], &[1.0; 5]); + assert_invalid(r, "value", 3); +} + +#[test] +fn infinite_value() { + let mut p = problem(); + let mut v = vec![0.0; 5]; + v[3] = f64::INFINITY; + let r = p.add_value(&pts(5), &v, &[1.0; 5]); + assert_invalid(r, "value", 3); +} + +#[test] +fn array_of_wrong_length() { + let mut p = problem(); + // 5 values for the points: not a multiple of 3. + let r = p.add_value(&[1.0; 5], &[0.0; 2], &[1.0; 2]); + assert!( + matches!(r, Err(CoreError::InvalidInput { .. })), + "got {r:?}" + ); + // Values array has a different length than the points. + let r = p.add_value(&pts(4), &[0.0; 3], &[1.0; 4]); + assert!( + matches!(r, Err(CoreError::InvalidInput { .. })), + "got {r:?}" + ); +} + +fn zero_vector_input() -> Vec { + let mut v: Vec = (0..5).flat_map(|_| [0.0, 0.0, 1.0]).collect(); + v[3 * 3..3 * 3 + 3].copy_from_slice(&[0.0, 0.0, 0.0]); // row 3 + v +} + +#[test] +fn zero_vector_gradient() { + let mut p = problem(); + let r = p.add_gradient(&pts(5), &zero_vector_input(), &[1.0; 5]); + assert_invalid(r, "gradient", 3); +} + +#[test] +fn zero_vector_normal() { + let mut p = problem(); + let r = p.add_normal(&pts(5), &zero_vector_input(), &[1.0; 5]); + assert_invalid(r, "normal", 3); +} + +#[test] +fn zero_vector_tangent() { + let mut p = problem(); + let r = p.add_tangent(&pts(5), &zero_vector_input(), &[1.0; 5]); + assert_invalid(r, "tangent", 3); +} + +#[test] +fn weight_zero() { + let mut p = problem(); + let mut w = vec![1.0; 5]; + w[3] = 0.0; + let r = p.add_value(&pts(5), &[0.0; 5], &w); + assert_invalid(r, "value", 3); +} + +#[test] +fn weight_negative() { + let mut p = problem(); + let mut w = vec![1.0; 5]; + w[3] = -1.0; + let r = p.add_value(&pts(5), &[0.0; 5], &w); + assert_invalid(r, "value", 3); +} + +#[test] +fn value_bound_lower_above_upper() { + let mut p = problem(); + let lower = vec![0.0; 5]; + let mut upper = vec![1.0; 5]; + upper[3] = -1.0; // lower > upper at row 3 + let r = p.add_value_bound(&pts(5), &lower, &upper, &[1.0; 5]); + assert_invalid(r, "value_bound", 3); +} + +#[test] +fn pair_bound_lower_above_upper() { + let mut p = problem(); + let lower = vec![0.0; 5]; + let mut upper = vec![1.0; 5]; + upper[3] = -1.0; // lower > upper at row 3 + let r = p.add_pair_bound(&pts(5), &pts(5), &lower, &upper, &[1.0; 5]); + assert_invalid(r, "pair_bound", 3); +} + +#[test] +fn grid_with_two_nodes_on_one_axis() { + let r = Problem::new([0.0; 3], [5, 2, 5], [1.0; 3]); + assert!( + matches!(r, Err(CoreError::GridTooSmall { axis: 1, nodes: 2 })), + "got {:?}", + r.map(|_| ()) + ); +} + +#[test] +fn grid_above_max_nodes() { + // 2^21 * 2^21 * 2^21 = 2^63. This is more than isize::MAX / 8. + // The check must use u128 maths and must not allocate. + let n = 1usize << 21; + let r = Problem::new([0.0; 3], [n, n, n], [1.0; 3]); + assert!( + matches!(r, Err(CoreError::GridTooLarge { .. })), + "got {:?}", + r.map(|_| ()) + ); +} + +#[test] +fn start_with_zero_constraints() { + let p = problem(); + let r = p.start(Settings::default()); + assert!( + matches!(r, Err(CoreError::NoConstraints)), + "got {:?}", + r.map(|_| ()) + ); +} + +// Warnings: these are not errors. + +#[test] +fn point_outside_grid_is_dropped_with_warning() { + let mut p = problem(); + let mut x = pts(4); + x[0..3].copy_from_slice(&[100.0, 100.0, 100.0]); // one point outside + p.add_value(&x, &[0.0; 4], &[1.0; 4]) + .expect("outside point is not an error"); + assert_eq!(p.warnings().len(), 1, "warnings: {:?}", p.warnings()); + assert!(has_warning(&p, "1"), "warnings: {:?}", p.warnings()); +} + +#[test] +fn conflicting_values_at_one_point_give_warning() { + let mut p = problem(); + let x = [2.0, 2.0, 2.0, 2.0, 2.0, 2.0]; // two rows at one point + p.add_value(&x, &[1.0, 5.0], &[1.0, 1.0]) + .expect("conflict is not an error"); + assert!(has_warning(&p, "1"), "warnings: {:?}", p.warnings()); +} + +#[test] +fn step_ratio_above_100_gives_warning() { + let p = Problem::new([0.0; 3], [5, 5, 5], [1.0, 1.0, 150.0]).expect("valid grid"); + assert!(!p.warnings().is_empty(), "expected a step ratio warning"); +} diff --git a/packages/loop_interpolation_core/tests/memory.rs b/packages/loop_interpolation_core/tests/memory.rs new file mode 100644 index 00000000..122b42e5 --- /dev/null +++ b/packages/loop_interpolation_core/tests/memory.rs @@ -0,0 +1,150 @@ +//! Memory tests. They measure the peak heap use of a full solve. + +mod common; + +use loop_interpolation_core::{FiniteDifferenceInterpolator, Problem, Settings, StructuredGrid3D}; + +/// Small deterministic generator (LCG). It returns a number in [0, 1). +struct Lcg(u64); + +impl Lcg { + fn next(&mut self) -> f64 { + self.0 = self + .0 + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + ((self.0 >> 11) as f64) / ((1u64 << 53) as f64) + } +} + +/// Build and solve a problem on an n x n x n grid. +/// Return the peak bytes above the level at the start, and the dof. +fn run(n: usize, n_value: usize, n_normal: usize) -> (usize, usize) { + common::reset_peak(); + let start = common::current(); + + let grid = StructuredGrid3D::new([0.0, 0.0, 0.0], [n, n, n], [1.0, 1.0, 1.0]); + let mut interp = FiniteDifferenceInterpolator::new(grid); + let dof = interp.dof(); + let extent = (n - 1) as f64; + let mut rng = Lcg(12345); + + for _ in 0..n_value { + let p = [ + rng.next() * extent, + rng.next() * extent, + rng.next() * extent, + ]; + // The value is a plane field, so the data are consistent. + let v = p[0] + 0.5 * p[1] + 0.25 * p[2]; + interp.add_value_constraint(p, v, 1.0); + } + for _ in 0..n_normal { + let p = [ + rng.next() * extent, + rng.next() * extent, + rng.next() * extent, + ]; + interp.add_gradient_constraint(p, [1.0, 0.5, 0.25], 1.0); + } + + let result = interp.solve(5000, 1e-8); + println!( + "iterations={} converged={} residual={}", + result.iterations, result.converged, result.residual_norm + ); + drop(result); + + let peak = common::peak().saturating_sub(start); + (peak, dof) +} + +/// Problem R1: 100^3 grid, 2000 value and 500 normal constraints. +#[test] +#[ignore] +fn baseline_r1() { + let (peak, dof) = run(100, 2000, 500); + let bytes_per_dof = peak as f64 / dof as f64; + println!("bytes_per_dof={}", bytes_per_dof); + assert!(bytes_per_dof > 0.0); +} + +/// Small problem on a 30^3 grid. It only prints. +#[test] +fn baseline_small() { + let (peak, dof) = run(30, 200, 50); + println!("small bytes_per_dof={}", peak as f64 / dof as f64); +} + +/// The tables of the interpolator use at most 12 bytes per dof (40^3 grid). +/// The tables are built on first use, so the test does one short solve first. +#[test] +fn table_bytes_within_12_per_dof() { + let grid = StructuredGrid3D::new([0.0, 0.0, 0.0], [40, 40, 40], [1.0, 1.0, 1.0]); + let mut interp = FiniteDifferenceInterpolator::new(grid); + interp.add_value_constraint([20.0, 20.0, 20.0], 1.0, 1.0); + let dof = interp.dof(); + assert_eq!(interp.table_bytes(), 0, "tables must be lazy"); + let _ = interp.solve(1, 1e-8); + let bytes = interp.table_bytes(); + assert!(bytes > 0, "the solve must build the tables"); + println!( + "table_bytes={} per_dof={}", + bytes, + bytes as f64 / dof as f64 + ); + assert!(bytes <= 12 * dof, "table_bytes={bytes} dof={dof}"); +} + +/// Problem R1 through the solve session: same data as `baseline_r1`. +/// The session keeps four full vectors and the inverse diagonal. +#[test] +#[ignore] +fn session_r1() { + let (n, n_value, n_normal) = (100usize, 2000usize, 500usize); + common::reset_peak(); + let start = common::current(); + + let mut p = Problem::new([0.0; 3], [n; 3], [1.0; 3]).unwrap(); + let extent = (n - 1) as f64; + let mut rng = Lcg(12345); + let mut pts = Vec::with_capacity(3 * n_value); + let mut vals = Vec::with_capacity(n_value); + for _ in 0..n_value { + let q = [ + rng.next() * extent, + rng.next() * extent, + rng.next() * extent, + ]; + vals.push(q[0] + 0.5 * q[1] + 0.25 * q[2]); + pts.extend_from_slice(&q); + } + p.add_value(&pts, &vals, &vec![1.0; n_value]).unwrap(); + let mut pts = Vec::with_capacity(3 * n_normal); + let mut dirs = Vec::with_capacity(3 * n_normal); + for _ in 0..n_normal { + pts.extend_from_slice(&[ + rng.next() * extent, + rng.next() * extent, + rng.next() * extent, + ]); + dirs.extend_from_slice(&[1.0, 0.5, 0.25]); + } + p.add_gradient(&pts, &dirs, &vec![1.0; n_normal]).unwrap(); + + let dof = n * n * n; + let (field, report) = p + .solve(Settings { + tol: 1e-8, + max_iter: 5000, + ..Settings::default() + }) + .unwrap(); + println!( + "iterations={} converged={} residual={}", + report.iterations, report.converged, report.residual + ); + drop(field); + let peak = common::peak().saturating_sub(start); + println!("bytes_per_dof={}", peak as f64 / dof as f64); +} diff --git a/packages/loop_interpolation_core/tests/memory_estimate.rs b/packages/loop_interpolation_core/tests/memory_estimate.rs new file mode 100644 index 00000000..004a3b3e --- /dev/null +++ b/packages/loop_interpolation_core/tests/memory_estimate.rs @@ -0,0 +1,202 @@ +//! Tests of the memory estimate and the budget check (story 4). +//! The tests use the counting allocator of `common`. + +mod common; + +use std::sync::{Mutex, MutexGuard}; + +use loop_interpolation_core::{CoreError, Problem, Settings}; + +/// The counting allocator is global for this test binary. +/// Tests run in parallel threads. Each test takes this lock, +/// so that only one test at a time measures allocation. +static SERIAL: Mutex<()> = Mutex::new(()); + +fn lock() -> MutexGuard<'static, ()> { + // A failed test poisons the lock. Ignore the poison. + SERIAL.lock().unwrap_or_else(|e| e.into_inner()) +} + +/// Small deterministic generator (LCG). It returns a number in [0, 1). +struct Lcg(u64); + +impl Lcg { + fn next(&mut self) -> f64 { + self.0 = self + .0 + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + ((self.0 >> 11) as f64) / ((1u64 << 53) as f64) + } +} + +/// Make `n` flat random points inside a grid of `nodes` per axis. +fn points(rng: &mut Lcg, n: usize, nodes: usize) -> Vec { + let extent = (nodes - 1) as f64; + (0..3 * n).map(|_| rng.next() * extent).collect() +} + +/// Make a problem like R1: a cubic grid, value rows and normal rows. +fn r1_problem(nodes: usize, n_value: usize, n_normal: usize) -> Problem { + let mut p = Problem::new([0.0; 3], [nodes; 3], [1.0; 3]).expect("grid must be valid"); + let mut rng = Lcg(12345); + let pts = points(&mut rng, n_value, nodes); + let values: Vec = (0..n_value) + .map(|i| pts[3 * i] + 0.5 * pts[3 * i + 1] + 0.25 * pts[3 * i + 2]) + .collect(); + p.add_value(&pts, &values, &vec![1.0; n_value]) + .expect("value rows must be valid"); + let pts = points(&mut rng, n_normal, nodes); + let normals: Vec = (0..n_normal).flat_map(|_| [1.0, 0.5, 0.25]).collect(); + p.add_normal(&pts, &normals, &vec![1.0; n_normal]) + .expect("normal rows must be valid"); + p +} + +const MB: u64 = 1_000_000; + +// The estimate for 100^3 with R1 data is between 40 MB and 100 MB. +// All parts are more than 0. +#[test] +fn estimate_100_cubed_is_in_range() { + let _guard = lock(); + let p = r1_problem(100, 2000, 500); + let e = p + .estimate_memory(&Settings::default()) + .expect("estimate must succeed"); + assert!(e.vectors > 0, "vectors must be more than 0"); + assert!(e.constraints > 0, "constraints must be more than 0"); + assert!(e.working > 0, "working must be more than 0"); + assert!( + e.total >= 40 * MB && e.total <= 100 * MB, + "total {} is outside 40 MB to 100 MB", + e.total + ); +} + +// The estimate grows in a straight line with the problem size. +// 60^3 has 8 times the nodes of 30^3. It also has 8 times the rows +// (the same density), because the constraint records are part of the estimate. +// The shell and border scratch grow with the surface (n^2), so the ratio is +// below 8 for small grids. A ratio near 4 would mean that memory grows with +// n^2 only. A ratio above 8.5 would mean that memory grows faster than the +// problem size. +#[test] +fn estimate_scales_in_a_straight_line() { + let _guard = lock(); + let s = Settings::default(); + let small = r1_problem(30, 250, 64) + .estimate_memory(&s) + .expect("estimate must succeed") + .total; + let large = r1_problem(60, 2000, 512) + .estimate_memory(&s) + .expect("estimate must succeed") + .total; + let ratio = large as f64 / small as f64; + assert!( + (5.5..=8.5).contains(&ratio), + "ratio {ratio} is outside 5.5 to 8.5" + ); +} + +// The estimate call allocates less than 1 MB. +#[test] +fn estimate_call_allocates_less_than_1_mb() { + let _guard = lock(); + let p = r1_problem(100, 2000, 500); + let s = Settings::default(); + common::reset_peak(); + let start = common::current(); + let result = p.estimate_memory(&s); + let used = common::peak().saturating_sub(start); + assert!(result.is_ok(), "estimate must succeed"); + assert!(used < 1_000_000, "estimate allocated {used} bytes"); +} + +// The constructor on a 200^3 grid allocates less than 1 MB. +// The constructor must not build the grid tables. +#[test] +fn problem_new_does_not_allocate() { + let _guard = lock(); + common::reset_peak(); + let start = common::current(); + let p = Problem::new([0.0; 3], [200; 3], [1.0; 3]); + let used = common::peak().saturating_sub(start); + assert!(p.is_ok(), "constructor must succeed"); + assert!(used < 1_000_000, "constructor allocated {used} bytes"); +} + +// A budget of 1 MB is too small for 100^3. +// `start` returns OverBudget before it allocates the vectors. +#[test] +fn start_refuses_a_budget_that_is_too_small() { + let _guard = lock(); + let budget = 1_000_000u64; + let p = r1_problem(100, 2000, 500); + let settings = Settings { + budget_bytes: Some(budget), + ..Settings::default() + }; + common::reset_peak(); + let start = common::current(); + let result = p.start(settings.clone()); + let used = common::peak().saturating_sub(start); + let max_nodes = match result { + Err(CoreError::OverBudget { + estimate_bytes, + budget_bytes, + max_nodes_per_axis, + }) => { + assert_eq!(budget_bytes, budget); + assert!(estimate_bytes > budget, "estimate must be over the budget"); + max_nodes_per_axis + } + Err(e) => panic!("expected OverBudget, got {e:?}"), + Ok(_) => panic!("expected OverBudget, got a session"), + }; + assert!( + (used as u64) < budget, + "start allocated {used} bytes; budget is {budget}" + ); + assert!(max_nodes >= 1, "max_nodes_per_axis must be at least 1"); + + // A cubic grid of that size must fit in the budget. + // A grid has at least 3 nodes on each axis. + if max_nodes >= 3 { + let small = r1_problem(max_nodes, 2000, 500); + let e = small + .estimate_memory(&Settings::default()) + .expect("estimate must succeed"); + assert!( + e.total <= budget, + "estimate {} of {max_nodes}^3 is over the budget", + e.total + ); + } +} + +// Pair bounds are counted. 100 000 pair bounds add at least +// 100 000 * 200 bytes to `constraints`. +#[test] +fn pair_bounds_are_counted_in_constraints() { + let _guard = lock(); + let nodes = 30; + let n = 100_000usize; + let s = Settings::default(); + let mut p = r1_problem(nodes, 100, 100); + let before = p.estimate_memory(&s).expect("estimate must succeed"); + + let mut rng = Lcg(777); + let a = points(&mut rng, n, nodes); + let b = points(&mut rng, n, nodes); + p.add_pair_bound(&a, &b, &vec![-1.0; n], &vec![1.0; n], &vec![1.0; n]) + .expect("pair bounds must be valid"); + let after = p.estimate_memory(&s).expect("estimate must succeed"); + + let added = after.constraints.saturating_sub(before.constraints); + assert!( + added >= 100_000 * 200, + "constraints grew by {added} bytes; at least 20000000 are needed" + ); +} diff --git a/packages/loop_interpolation_core/tests/parity_fixture.rs b/packages/loop_interpolation_core/tests/parity_fixture.rs new file mode 100644 index 00000000..210dadf1 --- /dev/null +++ b/packages/loop_interpolation_core/tests/parity_fixture.rs @@ -0,0 +1,242 @@ +//! Parity test (T026): the Rust solver must agree with the matrix solver. +//! +//! The file `fixtures/fixture_20.bin` has a 20^3 problem and the expected +//! field of the Python matrix solver. The format is in plan.md, section D8. +//! This test reads the file with the standard library only. + +use loop_interpolation_core::{ + BorderRegularisationWeights, Problem, RegularisationWeights, Settings, +}; + +/// The largest allowed difference, as a part of the range of the field. +const MAX_REL_DIFF: f64 = 1e-6; + +/// The ridge factor that the ported interpolator uses (`ridge_factor` default). +/// `Problem` cannot set the ridge, so the file must have this value. +const CORE_RIDGE: f64 = 1e-8; + +/// The data of the fixture file, in the order of the file. +struct Fixture { + origin: Vec, + counts: Vec, + step: Vec, + reg: Vec, + border: Vec, + ridge: Vec, + value_points: Vec, + value_values: Vec, + value_weights: Vec, + gradient_points: Vec, + gradient_vectors: Vec, + gradient_weights: Vec, + normal_points: Vec, + normal_vectors: Vec, + normal_weights: Vec, + tangent_points: Vec, + tangent_vectors: Vec, + tangent_weights: Vec, + interface_points: Vec, + interface_ids: Vec, + interface_weight: Vec, + expected_field: Vec, +} + +/// Read the next block: a `u32` length and then that number of `f64` values. +fn read_block(bytes: &[u8], pos: &mut usize) -> Vec { + let len_end = *pos + 4; + assert!( + len_end <= bytes.len(), + "fixture is too short (block length)" + ); + let n = u32::from_le_bytes(bytes[*pos..len_end].try_into().unwrap()) as usize; + *pos = len_end; + let data_end = *pos + 8 * n; + assert!(data_end <= bytes.len(), "fixture is too short (block data)"); + // The length is 8 n, so there is no remainder. + let values = bytes[*pos..data_end] + .as_chunks::<8>() + .0 + .iter() + .map(|c| f64::from_le_bytes(*c)) + .collect(); + *pos = data_end; + values +} + +fn load_fixture() -> Fixture { + let path = concat!(env!("CARGO_MANIFEST_DIR"), "/fixtures/fixture_20.bin"); + let bytes = std::fs::read(path).expect("cannot read fixture_20.bin"); + assert!(bytes.len() >= 4, "fixture is too short (magic)"); + assert_eq!(&bytes[0..4], b"LIF1", "wrong magic in fixture"); + let mut pos = 4usize; + let mut next = || read_block(&bytes, &mut pos); + let fx = Fixture { + origin: next(), + counts: next(), + step: next(), + reg: next(), + border: next(), + ridge: next(), + value_points: next(), + value_values: next(), + value_weights: next(), + gradient_points: next(), + gradient_vectors: next(), + gradient_weights: next(), + normal_points: next(), + normal_vectors: next(), + normal_weights: next(), + tangent_points: next(), + tangent_vectors: next(), + tangent_weights: next(), + interface_points: next(), + interface_ids: next(), + interface_weight: next(), + expected_field: next(), + }; + assert_eq!(pos, bytes.len(), "fixture has extra bytes at the end"); + fx +} + +/// Make a weight vector with one value for each of `n` points. +/// If the file has one value only, repeat it. +fn per_point(weights: &[f64], n: usize) -> Vec { + if weights.len() == n { + weights.to_vec() + } else if weights.len() == 1 { + vec![weights[0]; n] + } else { + panic!("weights have {} values; {} are needed", weights.len(), n); + } +} + +#[test] +fn rust_solver_agrees_with_matrix_solver_fixture() { + let fx = load_fixture(); + + assert_eq!(fx.origin.len(), 3); + assert_eq!(fx.counts.len(), 3); + assert_eq!(fx.step.len(), 3); + assert_eq!(fx.reg.len(), 6); + assert_eq!(fx.border.len(), 3); + assert_eq!(fx.ridge.len(), 1); + assert_eq!(fx.interface_weight.len(), 1); + // The ridge of the file must be the ridge of the ported interpolator. + assert_eq!( + fx.ridge[0], CORE_RIDGE, + "the ridge of the file is not the ridge of the core" + ); + + let counts = [ + fx.counts[0] as usize, + fx.counts[1] as usize, + fx.counts[2] as usize, + ]; + let n_nodes = counts[0] * counts[1] * counts[2]; + assert_eq!(fx.expected_field.len(), n_nodes); + + let mut problem = Problem::new( + [fx.origin[0], fx.origin[1], fx.origin[2]], + counts, + [fx.step[0], fx.step[1], fx.step[2]], + ) + .expect("cannot make the problem"); + + // The weights of the file are in core meaning. Pass them as they are. + problem.set_regularisation(RegularisationWeights { + dxx: fx.reg[0], + dyy: fx.reg[1], + dzz: fx.reg[2], + dxy: fx.reg[3], + dxz: fx.reg[4], + dyz: fx.reg[5], + }); + problem.set_border_regularisation(BorderRegularisationWeights { + dx: fx.border[0], + dy: fx.border[1], + dz: fx.border[2], + }); + + let n_value = fx.value_points.len() / 3; + problem + .add_value( + &fx.value_points, + &fx.value_values, + &per_point(&fx.value_weights, n_value), + ) + .expect("value constraints"); + + let n_gradient = fx.gradient_points.len() / 3; + problem + .add_gradient( + &fx.gradient_points, + &fx.gradient_vectors, + &per_point(&fx.gradient_weights, n_gradient), + ) + .expect("gradient constraints"); + + let n_normal = fx.normal_points.len() / 3; + problem + .add_normal( + &fx.normal_points, + &fx.normal_vectors, + &per_point(&fx.normal_weights, n_normal), + ) + .expect("normal constraints"); + + let n_tangent = fx.tangent_points.len() / 3; + problem + .add_tangent( + &fx.tangent_points, + &fx.tangent_vectors, + &per_point(&fx.tangent_weights, n_tangent), + ) + .expect("tangent constraints"); + + problem + .add_interface( + &fx.interface_points, + &fx.interface_ids, + fx.interface_weight[0], + ) + .expect("interface constraints"); + + let settings = Settings { + tol: 1e-10, + max_iter: 50_000, + ..Settings::default() + }; + let (field, report) = problem.solve(settings).expect("solve failed"); + assert!(report.converged, "the solver did not converge: {report:?}"); + + let got = field.values_f64(); + assert_eq!(got.len(), n_nodes); + + let lo = fx + .expected_field + .iter() + .cloned() + .fold(f64::INFINITY, f64::min); + let hi = fx + .expected_field + .iter() + .cloned() + .fold(f64::NEG_INFINITY, f64::max); + let range = hi - lo; + assert!(range > 0.0, "the expected field has no range"); + + let (worst_index, max_diff) = got + .iter() + .zip(fx.expected_field.iter()) + .map(|(a, b)| (a - b).abs()) + .enumerate() + .fold( + (0usize, 0.0f64), + |acc, (i, d)| if d > acc.1 { (i, d) } else { acc }, + ); + let rel = max_diff / range; + assert!( + rel <= MAX_REL_DIFF, + "parity failed: max difference {max_diff:e} at node {worst_index} is {rel:e} of the range {range:e} (limit {MAX_REL_DIFF:e}); check the mapping" + ); +} diff --git a/packages/loop_interpolation_core/tests/precision_f32.rs b/packages/loop_interpolation_core/tests/precision_f32.rs new file mode 100644 index 00000000..589ce22b --- /dev/null +++ b/packages/loop_interpolation_core/tests/precision_f32.rs @@ -0,0 +1,198 @@ +//! Single precision tests (T046, story 5, FR-013, FR-022, SC-005). +//! F32 must use at most 60 percent of the F64 memory, must agree with F64, +//! and must report its residual and its convergence honestly. + +mod common; + +use loop_interpolation_core::{Field, FieldData, Precision, Problem, Settings, SolveReport}; +use std::sync::Mutex; + +/// Only one test at a time may use the counting allocator. +static LOCK: Mutex<()> = Mutex::new(()); + +/// Small deterministic generator (LCG). It returns a number in [0, 1). +struct Lcg(u64); + +impl Lcg { + fn next(&mut self) -> f64 { + self.0 = self + .0 + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + ((self.0 >> 11) as f64) / ((1u64 << 53) as f64) + } +} + +/// Build the R1-type problem: value and gradient data from a plane field. +/// Each call builds the same problem (the seed is fixed). +fn build(n: usize, n_value: usize, n_normal: usize) -> Problem { + let mut p = Problem::new([0.0; 3], [n; 3], [1.0; 3]).unwrap(); + let extent = (n - 1) as f64; + let mut rng = Lcg(12345); + let mut pts = Vec::with_capacity(3 * n_value); + let mut vals = Vec::with_capacity(n_value); + for _ in 0..n_value { + let q = [ + rng.next() * extent, + rng.next() * extent, + rng.next() * extent, + ]; + vals.push(q[0] + 0.5 * q[1] + 0.25 * q[2]); + pts.extend_from_slice(&q); + } + p.add_value(&pts, &vals, &vec![1.0; n_value]).unwrap(); + let mut pts = Vec::with_capacity(3 * n_normal); + let mut dirs = Vec::with_capacity(3 * n_normal); + for _ in 0..n_normal { + pts.extend_from_slice(&[ + rng.next() * extent, + rng.next() * extent, + rng.next() * extent, + ]); + dirs.extend_from_slice(&[1.0, 0.5, 0.25]); + } + p.add_gradient(&pts, &dirs, &vec![1.0; n_normal]).unwrap(); + p +} + +fn settings(precision: Precision, tol: f64) -> Settings { + Settings { + precision, + tol, + max_iter: 20_000, + ..Settings::default() + } +} + +/// Result of one measured solve. +struct Run { + peak: usize, + field: Field, + report: SolveReport, +} + +/// Build and solve. The peak is above the level at the start. It includes +/// the build of the Problem, for F32 and for F64 in the same way. +fn measure(n: usize, precision: Precision, tol: f64) -> Run { + common::reset_peak(); + let start = common::current(); + let p = build(n, 500, 125); + let (field, report) = p.solve(settings(precision, tol)).unwrap(); + let peak = common::peak().saturating_sub(start); + Run { + peak, + field, + report, + } +} + +fn range_of(v: &[f64]) -> f64 { + let lo = v.iter().cloned().fold(f64::INFINITY, f64::min); + let hi = v.iter().cloned().fold(f64::NEG_INFINITY, f64::max); + hi - lo +} + +/// Relative difference of two numbers. +fn rel_diff(a: f64, b: f64) -> f64 { + (a - b).abs() / b.abs().max(f64::MIN_POSITIVE) +} + +/// Tolerance for F32. F32 cannot reach the F64 tolerance. +const TOL_F32: f64 = 1e-5; +const TOL_F64: f64 = 1e-8; + +/// (1) The peak of F32 is at most 0.6 times the peak of F64. +#[test] +fn f32_peak_is_at_most_60_percent_of_f64_peak() { + let _guard = LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let r64 = measure(50, Precision::F64, TOL_F64); + let r32 = measure(50, Precision::F32, TOL_F32); + println!( + "peak f64={} f32={} report peak f64={} f32={}", + r64.peak, r32.peak, r64.report.peak_bytes, r32.report.peak_bytes + ); + assert!( + r32.peak as f64 <= 0.6 * r64.peak as f64, + "F32 peak {}, F64 peak {}", + r32.peak, + r64.peak + ); +} + +/// (2) The F32 field differs from the F64 field by at most 1e-3 of the range. +#[test] +fn f32_field_agrees_with_f64_field() { + let _guard = LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let r64 = measure(50, Precision::F64, TOL_F64); + let r32 = measure(50, Precision::F32, TOL_F32); + let v64 = r64.field.values_f64(); + let v32 = r32.field.values_f64(); + assert_eq!(v64.len(), v32.len()); + let range = range_of(&v64); + let max_diff = v64 + .iter() + .zip(v32.iter()) + .map(|(a, b)| (a - b).abs()) + .fold(0.0, f64::max); + assert!( + max_diff <= 1e-3 * range, + "largest difference {max_diff}, range {range}" + ); +} + +/// (3) The report residual matches the residual that `residual_f64` computes, +/// in both modes. +#[test] +fn report_residual_matches_residual_f64() { + let _guard = LOCK.lock().unwrap_or_else(|e| e.into_inner()); + for (precision, tol) in [(Precision::F64, TOL_F64), (Precision::F32, TOL_F32)] { + // `solve` consumes the problem. Build a second copy for the check. + let kept = build(50, 500, 125); + let (field, report) = build(50, 500, 125).solve(settings(precision, tol)).unwrap(); + let own = kept.residual_f64(&field); + let rel = rel_diff(report.residual, own); + assert!( + rel <= 1e-6, + "{precision:?}: report residual {}, residual_f64 {own}, relative difference {rel}", + report.residual + ); + } +} + +/// (4) F32 with tol = 1e-12 does not converge, and the report says so. +#[test] +fn f32_with_tiny_tolerance_reports_not_converged() { + let _guard = LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let (_field, report) = build(20, 100, 25) + .solve(Settings { + precision: Precision::F32, + tol: 1e-12, + max_iter: 2000, + ..Settings::default() + }) + .unwrap(); + assert!( + !report.converged, + "F32 cannot reach 1e-12, residual {}", + report.residual + ); + assert!( + report.residual > 1e-12, + "residual {} is below the tolerance but converged is false", + report.residual + ); +} + +/// (5) The Field in F32 mode holds a Vec. The F64 field holds a Vec. +#[test] +fn f32_field_holds_f32_values() { + let _guard = LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let (field, _) = build(20, 100, 25) + .solve(settings(Precision::F32, TOL_F32)) + .unwrap(); + assert!(matches!(field.data, FieldData::F32(_))); + let (field, _) = build(20, 100, 25) + .solve(settings(Precision::F64, TOL_F64)) + .unwrap(); + assert!(matches!(field.data, FieldData::F64(_))); +} diff --git a/packages/loop_interpolation_core/tests/reference_problems.rs b/packages/loop_interpolation_core/tests/reference_problems.rs new file mode 100644 index 00000000..a951a204 --- /dev/null +++ b/packages/loop_interpolation_core/tests/reference_problems.rs @@ -0,0 +1,245 @@ +//! Reference problem tests (SC-001, SC-007, SC-008, SC-009, SC-012). +//! The tests use the counting allocator of `common`. +//! The CI tests use small iteration counts. The big tests are ignored. + +mod common; + +use std::sync::{Mutex, MutexGuard}; +use std::time::Instant; + +use loop_interpolation_core::{Precision, Problem, Settings}; + +/// The counting allocator is global for this test binary. +/// Each test takes this lock, so that only one test measures at a time. +static SERIAL: Mutex<()> = Mutex::new(()); + +fn lock() -> MutexGuard<'static, ()> { + // A failed test poisons the lock. Ignore the poison. + SERIAL.lock().unwrap_or_else(|e| e.into_inner()) +} + +/// Small deterministic generator (LCG). It returns a number in [0, 1). +struct Lcg(u64); + +impl Lcg { + fn next(&mut self) -> f64 { + self.0 = self + .0 + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + ((self.0 >> 11) as f64) / ((1u64 << 53) as f64) + } +} + +/// Build a problem with the data generator of `session_r1` in tests/memory.rs: +/// a plane field, `n_value` value rows and `n_normal` gradient rows. +fn build(nodes: usize, n_value: usize, n_normal: usize) -> Problem { + let mut p = Problem::new([0.0; 3], [nodes; 3], [1.0; 3]).expect("grid must be valid"); + let mut rng = Lcg(12345); + let extent = (nodes - 1) as f64; + let mut pts = Vec::with_capacity(3 * n_value); + let mut vals = Vec::with_capacity(n_value); + for _ in 0..n_value { + let q = [ + rng.next() * extent, + rng.next() * extent, + rng.next() * extent, + ]; + vals.push(q[0] + 0.5 * q[1] + 0.25 * q[2]); + pts.extend_from_slice(&q); + } + p.add_value(&pts, &vals, &vec![1.0; n_value]) + .expect("value rows must be valid"); + let mut pts = Vec::with_capacity(3 * n_normal); + let mut dirs = Vec::with_capacity(3 * n_normal); + for _ in 0..n_normal { + pts.extend_from_slice(&[ + rng.next() * extent, + rng.next() * extent, + rng.next() * extent, + ]); + dirs.extend_from_slice(&[1.0, 0.5, 0.25]); + } + p.add_gradient(&pts, &dirs, &vec![1.0; n_normal]) + .expect("gradient rows must be valid"); + p +} + +/// Start the thread pool (feature `parallel`) once, before the first measurement. +/// The pool uses memory one time only. The memory does not belong to a solve. +fn warm_up() { + static ONCE: std::sync::Once = std::sync::Once::new(); + ONCE.call_once(|| { + let p = build(12, 20, 5); + let s = Settings { + max_iter: 3, + ..Settings::default() + }; + let _ = p.solve(s); + }); +} + +/// Build and solve. Return the peak bytes above the level at the start +/// (the build of the Problem is included), the estimate total, and the +/// solve time in seconds. +fn measure(nodes: usize, n_value: usize, n_normal: usize, settings: Settings) -> (usize, u64, f64) { + warm_up(); + common::reset_peak(); + let start = common::current(); + let p = build(nodes, n_value, n_normal); + let estimate = p + .estimate_memory(&settings) + .expect("estimate must succeed") + .total; + let t0 = Instant::now(); + let (field, _report) = p.solve(settings).expect("solve must succeed"); + let secs = t0.elapsed().as_secs_f64(); + drop(field); + let peak = common::peak().saturating_sub(start); + (peak, estimate, secs) +} + +/// Settings for a short solve. All vectors exist from the start, +/// so the peak is the same as for a full solve. +fn short_settings() -> Settings { + Settings { + max_iter: 20, + ..Settings::default() + } +} + +/// Check that the estimate is not lower than the peak and at most 1.25 times it. +fn check_estimate(sizes: &[usize]) { + for &n in sizes { + let (peak, estimate, _) = measure(n, 500, 125, short_settings()); + let ratio = estimate as f64 / peak as f64; + println!("n={n} estimate={estimate} peak={peak} ratio={ratio:.3}"); + assert!(estimate as usize >= peak, "n={n}: estimate below peak"); + assert!(ratio <= 1.25, "n={n}: estimate ratio {ratio} above 1.25"); + } +} + +/// Least-squares fit y = a + b x. Return the R^2 of the fit. +fn r_squared(x: &[f64], y: &[f64]) -> f64 { + let n = x.len() as f64; + let mx = x.iter().sum::() / n; + let my = y.iter().sum::() / n; + let sxx: f64 = x.iter().map(|v| (v - mx).powi(2)).sum(); + let sxy: f64 = x.iter().zip(y).map(|(a, b)| (a - mx) * (b - my)).sum(); + let syy: f64 = y.iter().map(|v| (v - my).powi(2)).sum(); + let b = sxy / sxx; + let a = my - b * mx; + let ss_res: f64 = x + .iter() + .zip(y) + .map(|(xi, yi)| (yi - (a + b * xi)).powi(2)) + .sum(); + if syy == 0.0 { 1.0 } else { 1.0 - ss_res / syy } +} + +/// Check that the peak is linear in the dof for the given grid sizes. +fn check_linear(sizes: &[usize]) { + let mut dofs = Vec::new(); + let mut peaks = Vec::new(); + for &n in sizes { + let (peak, _, _) = measure(n, 500, 125, short_settings()); + println!("n={n} dof={} peak={peak}", n * n * n); + dofs.push((n * n * n) as f64); + peaks.push(peak as f64); + } + let r2 = r_squared(&dofs, &peaks); + println!("r_squared={r2}"); + assert!(r2 >= 0.99, "R^2 {r2} below 0.99"); +} + +/// SC-009: ten grid sizes from 10^3 to 100^3. +#[test] +fn estimate_matches_peak_ci() { + let _guard = lock(); + check_estimate(&[10, 15, 20, 30, 40, 50, 60, 70, 85, 100]); +} + +/// SC-012: peak against dof is a line (CI sizes). +#[test] +fn peak_is_linear_in_dof_ci() { + let _guard = lock(); + check_linear(&[30, 45, 60]); +} + +/// SC-012: the same check for 50^3, 100^3 and 150^3. +#[test] +#[ignore] +fn peak_is_linear_in_dof_large() { + let _guard = lock(); + check_linear(&[50, 100, 150]); +} + +/// SC-001: R1 peak is at most 100 MB. The time is printed. +#[test] +#[ignore] +fn r1_peak_within_100_mb() { + let _guard = lock(); + let settings = Settings { + tol: 1e-8, + max_iter: 5000, + ..Settings::default() + }; + let (peak, estimate, secs) = measure(100, 2000, 500, settings); + println!("R1 peak={peak} estimate={estimate} time_s={secs:.3}"); + assert!(peak <= 100_000_000, "R1 peak {peak} above 100 MB"); +} + +/// SC-008: 250^3 in F32 with a budget of 2 GB. The peak is at most 1.5 GB. +#[test] +#[ignore] +fn f32_250_cubed_within_budget() { + let _guard = lock(); + let settings = Settings { + precision: Precision::F32, + tol: 1e-5, + max_iter: 5000, + budget_bytes: Some(2_000_000_000), + ..Settings::default() + }; + let (peak, estimate, secs) = measure(250, 500, 125, settings); + println!("250^3 F32 peak={peak} estimate={estimate} time_s={secs:.3}"); + assert!(peak <= 1_500_000_000, "peak {peak} above 1.5 GB"); +} + +/// SC-009 extension: sizes up to 200^3. +#[test] +#[ignore] +fn estimate_matches_peak_to_200() { + let _guard = lock(); + check_estimate(&[10, 20, 40, 60, 80, 100, 120, 150, 175, 200]); +} + +/// SC-007 (report only): solve R1 fully and write the field as +/// little-endian f64 to `target/r1_field.f64` for the browser comparison. +#[test] +#[ignore] +fn r1_write_field() { + let _guard = lock(); + let p = build(100, 2000, 500); + let settings = Settings { + tol: 1e-8, + max_iter: 5000, + ..Settings::default() + }; + let t0 = Instant::now(); + let (field, report) = p.solve(settings).expect("solve must succeed"); + let secs = t0.elapsed().as_secs_f64(); + println!( + "R1 time_s={secs:.3} iterations={} converged={}", + report.iterations, report.converged + ); + let mut bytes = Vec::with_capacity(8 * 1_000_000); + for v in field.values_f64() { + bytes.extend_from_slice(&v.to_le_bytes()); + } + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("target"); + std::fs::create_dir_all(&dir).expect("target directory must be created"); + let path = dir.join("r1_field.f64"); + std::fs::write(&path, &bytes).expect("field file must be written"); + println!("written {} bytes to {}", bytes.len(), path.display()); +} diff --git a/packages/loop_interpolation_core/tests/scale_inequality.rs b/packages/loop_interpolation_core/tests/scale_inequality.rs new file mode 100644 index 00000000..40579a36 --- /dev/null +++ b/packages/loop_interpolation_core/tests/scale_inequality.rs @@ -0,0 +1,248 @@ +//! Memory tests with many active pair bounds (T023, story 2 scenario 3). +//! The pair bounds are active at the free answer: each bound is the free +//! difference of its two nodes. The peak heap use with the bounds must stay +//! near the peak without the bounds. + +mod common; + +use loop_interpolation_core::{Field, Problem, Settings, SolveReport}; +use std::sync::Mutex; +use std::time::Instant; + +/// Only one test at a time may use the counting allocator. +static LOCK: Mutex<()> = Mutex::new(()); + +/// Small deterministic generator (LCG). It returns a number in [0, 1). +struct Lcg(u64); + +impl Lcg { + fn next(&mut self) -> f64 { + self.0 = self + .0 + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + ((self.0 >> 11) as f64) / ((1u64 << 53) as f64) + } + + /// A number in 0..n. + fn below(&mut self, n: usize) -> usize { + ((self.next() * n as f64) as usize).min(n - 1) + } +} + +/// Node pairs. The points are at grid nodes (origin 0, step 1). +struct Pairs { + a_pts: Vec, + b_pts: Vec, + a_idx: Vec, + b_idx: Vec, +} + +impl Pairs { + fn len(&self) -> usize { + self.a_idx.len() + } +} + +/// Make `count` pairs of different nodes. The seed is fixed. +fn make_pairs(n: usize, count: usize) -> Pairs { + let mut rng = Lcg(777); + let mut pairs = Pairs { + a_pts: Vec::with_capacity(3 * count), + b_pts: Vec::with_capacity(3 * count), + a_idx: Vec::with_capacity(count), + b_idx: Vec::with_capacity(count), + }; + while pairs.len() < count { + let a = [rng.below(n), rng.below(n), rng.below(n)]; + let b = [rng.below(n), rng.below(n), rng.below(n)]; + if a == b { + continue; + } + pairs.a_pts.extend(a.iter().map(|&x| x as f64)); + pairs.b_pts.extend(b.iter().map(|&x| x as f64)); + pairs.a_idx.push(a[0] + n * (a[1] + n * a[2])); + pairs.b_idx.push(b[0] + n * (b[1] + n * b[2])); + } + pairs +} + +/// Pair bounds: the pairs, the lower bounds and the upper bounds. +type Bounds<'a> = Option<(&'a Pairs, &'a [f64], &'a [f64])>; + +/// Build the problem like R1: value and gradient data from a plane field. +/// Add the pair bounds when `bounds` is given. +fn build(n: usize, n_value: usize, n_normal: usize, bounds: Bounds) -> Problem { + let mut p = Problem::new([0.0; 3], [n; 3], [1.0; 3]).unwrap(); + let extent = (n - 1) as f64; + let mut rng = Lcg(12345); + let mut pts = Vec::with_capacity(3 * n_value); + let mut vals = Vec::with_capacity(n_value); + for _ in 0..n_value { + let q = [ + rng.next() * extent, + rng.next() * extent, + rng.next() * extent, + ]; + vals.push(q[0] + 0.5 * q[1] + 0.25 * q[2]); + pts.extend_from_slice(&q); + } + p.add_value(&pts, &vals, &vec![1.0; n_value]).unwrap(); + let mut pts = Vec::with_capacity(3 * n_normal); + let mut dirs = Vec::with_capacity(3 * n_normal); + for _ in 0..n_normal { + pts.extend_from_slice(&[ + rng.next() * extent, + rng.next() * extent, + rng.next() * extent, + ]); + dirs.extend_from_slice(&[1.0, 0.5, 0.25]); + } + p.add_gradient(&pts, &dirs, &vec![1.0; n_normal]).unwrap(); + if let Some((pairs, lower, upper)) = bounds { + p.add_pair_bound( + &pairs.a_pts, + &pairs.b_pts, + lower, + upper, + &vec![1.0; pairs.len()], + ) + .unwrap(); + } + p +} + +fn settings() -> Settings { + Settings { + tol: 1e-8, + max_iter: 20_000, + ..Settings::default() + } +} + +/// Result of one measured solve. +struct Run { + peak: usize, + seconds: f64, + field: Field, + report: SolveReport, +} + +/// Build and solve. The peak is above the level at the start and includes +/// the build of the Problem. +fn measure(n: usize, n_value: usize, n_normal: usize, bounds: Bounds) -> Run { + common::reset_peak(); + let start = common::current(); + let t = Instant::now(); + let p = build(n, n_value, n_normal, bounds); + let (field, report) = p.solve(settings()).unwrap(); + let seconds = t.elapsed().as_secs_f64(); + let peak = common::peak().saturating_sub(start); + Run { + peak, + seconds, + field, + report, + } +} + +fn range_of(v: &[f64]) -> f64 { + let lo = v.iter().cloned().fold(f64::INFINITY, f64::min); + let hi = v.iter().cloned().fold(f64::NEG_INFINITY, f64::max); + hi - lo +} + +/// Largest violation of the lower bound `a - b >= lower`. +fn largest_violation(values: &[f64], pairs: &Pairs, lower: &[f64]) -> f64 { + (0..pairs.len()) + .map(|i| (lower[i] - (values[pairs.a_idx[i]] - values[pairs.b_idx[i]])).max(0.0)) + .fold(0.0, f64::max) +} + +/// Solve without bounds, then with active bounds. Return both runs, the +/// pairs, the lower bounds and the range of the free field. +fn free_then_bounded( + n: usize, + n_value: usize, + n_normal: usize, + n_pairs: usize, +) -> (Run, Run, Pairs, Vec, f64) { + let free = measure(n, n_value, n_normal, None); + let values = free.field.values_f64(); + let range = range_of(&values); + let pairs = make_pairs(n, n_pairs); + // The lower bound is the free difference, so the bound is active. + let lower: Vec = (0..pairs.len()) + .map(|i| values[pairs.a_idx[i]] - values[pairs.b_idx[i]]) + .collect(); + let upper = vec![f64::INFINITY; pairs.len()]; + drop(values); + let bounded = measure(n, n_value, n_normal, Some((&pairs, &lower, &upper))); + (free, bounded, pairs, lower, range) +} + +/// 50^3 grid with 2 500 active pair bounds: the peak stays near the free peak. +#[test] +fn medium_active_bounds_keep_peak_near_free_peak() { + let _guard = LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let (free, bounded, pairs, lower, range) = free_then_bounded(50, 500, 125, 2500); + println!( + "free peak={} bounded peak={} iterations={} max_violation={}", + free.peak, bounded.peak, bounded.report.iterations, bounded.report.max_violation + ); + assert!(bounded.report.converged, "ADMM did not converge"); + assert!( + bounded.report.max_violation <= 1e-4 * range, + "report violation {}, range {range}", + bounded.report.max_violation + ); + let own = largest_violation(&bounded.field.values_f64(), &pairs, &lower); + assert!(own <= 1e-4 * range, "violation {own}, range {range}"); + assert!( + bounded.peak as f64 <= 1.2 * free.peak as f64, + "peak with bounds {}, peak without bounds {}", + bounded.peak, + free.peak + ); +} + +/// Run the 100^3 problem with `n_pairs` active pair bounds. Print the numbers. +fn big_run(n_pairs: usize) { + let _guard = LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let n = 100usize; + let (free, bounded, pairs, lower, range) = free_then_bounded(n, 2000, 500, n_pairs); + let dof = n * n * n; + let own = largest_violation(&bounded.field.values_f64(), &pairs, &lower); + println!("free: peak_bytes={} seconds={:.2}", free.peak, free.seconds); + println!( + "bounded ({n_pairs} pairs): peak_bytes={} bytes_per_dof={} seconds={:.2}", + bounded.peak, + bounded.peak as f64 / dof as f64, + bounded.seconds + ); + println!( + "ratio={:.3} report_peak_bytes={} iterations={} converged={}", + bounded.peak as f64 / free.peak as f64, + bounded.report.peak_bytes, + bounded.report.iterations, + bounded.report.converged + ); + println!( + "max_violation={} (report {}) range={range}", + own, bounded.report.max_violation + ); +} + +/// Problem R2: 100^3 grid, 200 active pair bounds. It only prints. +#[test] +#[ignore] +fn r2() { + big_run(200); +} + +/// Problem R3: 100^3 grid, 20 000 active pair bounds. It only prints. +#[test] +#[ignore] +fn r3() { + big_run(20_000); +} diff --git a/packages/loop_interpolation_core/tests/session_admm.rs b/packages/loop_interpolation_core/tests/session_admm.rs new file mode 100644 index 00000000..8e81c70a --- /dev/null +++ b/packages/loop_interpolation_core/tests/session_admm.rs @@ -0,0 +1,252 @@ +//! Tests of the ADMM solve session (T021, story 2). +//! Inequality bounds must hold, a bound that is not active must change +//! nothing, and bounds that cannot hold together must give `Infeasible`. +//! These tests fail until ADMM is implemented (T022). + +use loop_interpolation_core::{ + CoreError, Field, Precision, Problem, Progress, Settings, SolveReport, Step, ViolatedConstraint, +}; + +const N: usize = 20; +/// A bound that never acts. +const BIG: f64 = 1.0e6; + +/// Index of the node (i, j, k) in the field values. +fn idx(i: usize, j: usize, k: usize) -> usize { + i + N * (j + N * k) +} + +/// Node (i, j, k) as a point. The grid origin is 0 and the step is 1. +fn pt(i: usize, j: usize, k: usize) -> [f64; 3] { + [i as f64, j as f64, k as f64] +} + +/// Problem with data that rise along x: value = x on a set of nodes. +fn base_problem() -> Problem { + let mut p = Problem::new([0.0; 3], [N, N, N], [1.0; 3]).unwrap(); + let mut points = Vec::new(); + let mut values = Vec::new(); + let mut weights = Vec::new(); + for i in 2..=17 { + for &j in &[3usize, 9, 16] { + for &k in &[3usize, 9, 16] { + points.extend_from_slice(&pt(i, j, k)); + values.push(i as f64); + weights.push(1.0); + } + } + } + p.add_value(&points, &values, &weights).unwrap(); + p +} + +fn settings() -> Settings { + Settings { + tol: 1e-10, + max_iter: 20_000, + ..Settings::default() + } +} + +/// Run the session with small steps. Return the field, the report and +/// every progress that was reported. +fn run(p: Problem) -> (Field, SolveReport, Vec) { + let mut s = p.start(settings()).unwrap(); + let mut seen = Vec::new(); + for _ in 0..1_000_000 { + match s.step(25).unwrap() { + Step::Running(pr) => seen.push(pr), + Step::Done(_) => { + let (f, r) = s.finish().unwrap(); + return (f, r, seen); + } + } + } + panic!("the session does not finish"); +} + +fn range_of(v: &[f64]) -> f64 { + let lo = v.iter().cloned().fold(f64::INFINITY, f64::min); + let hi = v.iter().cloned().fold(f64::NEG_INFINITY, f64::max); + hi - lo +} + +/// Violation of a value bound: how far the value is outside [lo, hi]. +fn value_violation(v: f64, lo: f64, hi: f64) -> f64 { + (lo - v).max(v - hi).max(0.0) +} + +/// Violation of a pair bound: how far a - b is outside [lo, hi]. +fn pair_violation(a: f64, b: f64, lo: f64, hi: f64) -> f64 { + value_violation(a - b, lo, hi) +} + +/// Field without any bound. It shows that the data break the bounds. +fn free_values() -> Vec { + let (f, r, _) = run(base_problem()); + assert!(r.converged); + f.values_f64() +} + +/// (1) Value bound: upper bound 10 on a node where the free field is about 15. +#[test] +fn value_bound_holds() { + let free = free_values(); + let range = range_of(&free); + let at = idx(15, 9, 9); + assert!(free[at] > 12.0, "the data must break the bound"); + let mut p = base_problem(); + p.add_value_bound(&pt(15, 9, 9), &[-BIG], &[10.0], &[1.0]) + .unwrap(); + let (f, r, _) = run(p); + assert!(r.converged, "ADMM did not converge"); + let v = f.values_f64()[at]; + let viol = value_violation(v, -BIG, 10.0); + assert!(viol <= 1e-4 * range, "violation {viol}, range {range}"); +} + +/// (2) Pair bound A - B >= 0 against data that rise from A to B. +#[test] +fn pair_bound_holds() { + let free = free_values(); + let range = range_of(&free); + let (a, b) = (idx(5, 9, 9), idx(15, 9, 9)); + assert!(free[a] - free[b] < -5.0, "the data must break the bound"); + let mut p = base_problem(); + p.add_pair_bound(&pt(5, 9, 9), &pt(15, 9, 9), &[0.0], &[BIG], &[1.0]) + .unwrap(); + let (f, r, _) = run(p); + assert!(r.converged, "ADMM did not converge"); + let v = f.values_f64(); + let viol = pair_violation(v[a], v[b], 0.0, BIG); + assert!(viol <= 1e-4 * range, "violation {viol}, range {range}"); +} + +/// (5) Lower and upper bound on one row: both hold. The lower bound is +/// active on the first row, the upper bound on the second row. +#[test] +fn lower_and_upper_bound_hold() { + let free = free_values(); + let range = range_of(&free); + let (lo_node, hi_node) = (idx(3, 9, 9), idx(15, 9, 9)); + assert!(free[lo_node] < 5.0 && free[hi_node] > 6.0); + let mut p = base_problem(); + let mut points = Vec::new(); + points.extend_from_slice(&pt(3, 9, 9)); + points.extend_from_slice(&pt(15, 9, 9)); + p.add_value_bound(&points, &[5.0, 5.0], &[6.0, 6.0], &[1.0, 1.0]) + .unwrap(); + let (f, r, _) = run(p); + assert!(r.converged, "ADMM did not converge"); + let v = f.values_f64(); + for &n in &[lo_node, hi_node] { + let viol = value_violation(v[n], 5.0, 6.0); + assert!(viol <= 1e-4 * range, "violation {viol}, range {range}"); + } +} + +/// (4) A bound that is not active changes the field by at most 1e-8 of the range. +#[test] +fn inactive_bound_changes_nothing() { + let free = free_values(); + let range = range_of(&free); + let mut p = base_problem(); + p.add_value_bound(&pt(15, 9, 9), &[-BIG], &[BIG], &[1.0]) + .unwrap(); + p.add_pair_bound(&pt(5, 9, 9), &pt(15, 9, 9), &[-BIG], &[BIG], &[1.0]) + .unwrap(); + let (f, r, _) = run(p); + assert!(r.converged, "ADMM did not converge"); + let got = f.values_f64(); + assert_eq!(got.len(), free.len()); + let max_diff = got + .iter() + .zip(free.iter()) + .map(|(a, b)| (a - b).abs()) + .fold(0.0, f64::max); + assert!( + max_diff <= 1e-8 * range, + "max diff {max_diff} is over 1e-8 of range {range}" + ); +} + +/// (6) Two bounds that cannot hold together: `Infeasible`, at most 20 rows, +/// largest violation first. 30 nodes have the two rows, so at least 30 rows +/// are violated and the list must be cut to 20. +#[test] +fn infeasible_bounds_are_reported() { + let mut p = base_problem(); + let mut points = Vec::new(); + for i in 2..=16 { + for &j in &[6usize, 12] { + points.extend_from_slice(&pt(i, j, 9)); + } + } + let m = points.len() / 3; + assert_eq!(m, 30); + // Row set 1: value at least 12. Row set 2: value at most 5. + p.add_value_bound(&points, &vec![12.0; m], &vec![BIG; m], &vec![1.0; m]) + .unwrap(); + p.add_value_bound(&points, &vec![-BIG; m], &vec![5.0; m], &vec![1.0; m]) + .unwrap(); + match p.solve(settings()) { + Err(CoreError::Infeasible { worst }) => { + assert!( + !worst.is_empty() && worst.len() <= 20, + "len {}", + worst.len() + ); + assert_eq!(worst.len(), 20); + let sorted = worst + .windows(2) + .all(|w: &[ViolatedConstraint]| w[0].violation >= w[1].violation); + assert!(sorted, "not sorted by falling violation: {worst:?}"); + assert!(worst[0].violation > 0.0); + } + Err(e) => panic!("wrong error: {e}"), + Ok(_) => panic!("an infeasible problem must not solve"), + } +} + +/// Every `Running` progress has a finite `max_violation`. The final report has it. +#[test] +fn progress_and_report_have_max_violation() { + let free = free_values(); + let range = range_of(&free); + let mut p = base_problem(); + p.add_value_bound(&pt(15, 9, 9), &[-BIG], &[10.0], &[1.0]) + .unwrap(); + let (_f, r, seen) = run(p); + assert!(!seen.is_empty(), "no progress was reported"); + for pr in &seen { + assert!( + pr.max_violation.is_finite() && pr.max_violation >= 0.0, + "bad max_violation {}", + pr.max_violation + ); + } + assert!(r.max_violation.is_finite() && r.max_violation >= 0.0); + assert!( + r.max_violation <= 1e-4 * range, + "final max_violation {} over 1e-4 of range {range}", + r.max_violation + ); +} + +/// `Settings` has no `hybrid` or `active_set` field. This literal names every +/// field of D8 and has no `..Default::default()`. A new field would not compile. +#[test] +fn settings_has_only_the_d8_fields() { + let s = Settings { + precision: Precision::F64, + tol: 1e-8, + max_iter: 5000, + budget_bytes: None, + rho: 1.0, + inner_max_iter: 3000, + inner_tol: 1e-6, + admm_abs_tol: 1e-4, + admm_rel_tol: 1e-3, + }; + assert_eq!(s.max_iter, 5000); +} diff --git a/packages/loop_interpolation_core/tests/session_cg.rs b/packages/loop_interpolation_core/tests/session_cg.rs new file mode 100644 index 00000000..b9d9ae69 --- /dev/null +++ b/packages/loop_interpolation_core/tests/session_cg.rs @@ -0,0 +1,276 @@ +//! Tests of the CG solve session (T019). +//! The session must equal the one-shot solve, give progress, be able to +//! stop early, and free its memory when it is dropped. + +mod common; + +use std::sync::Mutex; + +use loop_interpolation_core::{ + FiniteDifferenceInterpolator, Problem, Settings, SolveSession, Stage, Step, StructuredGrid3D, +}; + +/// The tests that read the allocator counters must not run at the same time. +/// Every test takes this lock. The allocator counters are global, so a test that +/// runs at the same time would change the memory level of another test. +static MEM_LOCK: Mutex<()> = Mutex::new(()); + +const N: usize = 30; + +/// Small deterministic generator (LCG). It returns a number in [0, 1). +struct Lcg(u64); + +impl Lcg { + fn next(&mut self) -> f64 { + self.0 = self + .0 + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + ((self.0 >> 11) as f64) / ((1u64 << 53) as f64) + } +} + +/// Fixed data: flat arrays of points, values and normals. +struct Data { + vp: Vec, + vv: Vec, + vw: Vec, + np: Vec, + nv: Vec, + nw: Vec, +} + +fn data(n: usize, n_value: usize, n_normal: usize) -> Data { + let extent = (n - 1) as f64; + let mut rng = Lcg(2024); + let mut d = Data { + vp: vec![], + vv: vec![], + vw: vec![], + np: vec![], + nv: vec![], + nw: vec![], + }; + for _ in 0..n_value { + let p = [ + 1.0 + rng.next() * (extent - 2.0), + 1.0 + rng.next() * (extent - 2.0), + 1.0 + rng.next() * (extent - 2.0), + ]; + d.vv.push(p[0] + 0.5 * p[1] + 0.25 * p[2] + 0.1 * (p[0] * 0.3).sin()); + d.vp.extend_from_slice(&p); + d.vw.push(1.0); + } + for _ in 0..n_normal { + let p = [ + 1.0 + rng.next() * (extent - 2.0), + 1.0 + rng.next() * (extent - 2.0), + 1.0 + rng.next() * (extent - 2.0), + ]; + d.np.extend_from_slice(&p); + d.nv.extend_from_slice(&[1.0, 0.5, 0.25]); + d.nw.push(1.0); + } + d +} + +/// Build the problem with the flat API. +fn problem(n: usize, d: &Data) -> Problem { + let mut p = Problem::new([0.0; 3], [n, n, n], [1.0; 3]).unwrap(); + p.add_value(&d.vp, &d.vv, &d.vw).unwrap(); + p.add_normal(&d.np, &d.nv, &d.nw).unwrap(); + p +} + +fn settings(tol: f64, max_iter: usize) -> Settings { + Settings { + tol, + max_iter, + ..Settings::default() + } +} + +/// Reference: the ported one-shot solve with the same data. +fn reference(n: usize, d: &Data) -> loop_interpolation_core::CgResult { + let grid = StructuredGrid3D::new([0.0; 3], [n, n, n], [1.0; 3]); + let mut interp = FiniteDifferenceInterpolator::new(grid); + for i in 0..d.vv.len() { + interp.add_value_constraint( + [d.vp[3 * i], d.vp[3 * i + 1], d.vp[3 * i + 2]], + d.vv[i], + d.vw[i], + ); + } + for i in 0..d.nw.len() { + interp.add_norm_constraint( + [d.np[3 * i], d.np[3 * i + 1], d.np[3 * i + 2]], + [d.nv[3 * i], d.nv[3 * i + 1], d.nv[3 * i + 2]], + d.nw[i], + ); + } + interp.solve(5000, 1e-10) +} + +/// Run `step(k)` until `Done`. Return the report. +fn run_to_done(s: &mut SolveSession, k: usize) -> loop_interpolation_core::SolveReport { + for _ in 0..100_000 { + if let Step::Done(r) = s.step(k).unwrap() { + return r; + } + } + panic!("the session does not finish"); +} + +/// (1) The session field equals the one-shot field. +#[test] +fn session_equals_one_shot() { + let _g = MEM_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let d = data(N, 300, 60); + let want = reference(N, &d); + assert!(want.converged); + let mut s = problem(N, &d).start(settings(1e-10, 5000)).unwrap(); + run_to_done(&mut s, 7); + let (field, report) = s.finish().unwrap(); + assert!(report.converged); + let got = field.values_f64(); + assert_eq!(got.len(), want.x.len()); + let lo = want.x.iter().cloned().fold(f64::INFINITY, f64::min); + let hi = want.x.iter().cloned().fold(f64::NEG_INFINITY, f64::max); + let range = hi - lo; + let max_diff = got + .iter() + .zip(want.x.iter()) + .map(|(a, b)| (a - b).abs()) + .fold(0.0, f64::max); + assert!( + max_diff <= 1e-8 * range, + "max diff {max_diff} is over 1e-8 of range {range}" + ); +} + +/// (2) Progress: stage Solve, growing iteration, falling residual. +#[test] +fn progress_is_reported() { + let _g = MEM_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let d = data(N, 300, 60); + let mut s = problem(N, &d).start(settings(1e-10, 5000)).unwrap(); + let mut seen = Vec::new(); + for _ in 0..5 { + match s.step(7).unwrap() { + Step::Running(p) => { + assert!(matches!(p.stage, Stage::Solve)); + seen.push((p.iteration, p.residual)); + } + Step::Done(_) => panic!("the solve ended in 35 iterations"), + } + } + assert_eq!(seen.len(), 5); + for w in seen.windows(2) { + assert!(w[1].0 > w[0].0, "iteration does not grow: {seen:?}"); + } + assert!( + seen[4].1 < seen[0].1, + "residual does not fall over 5 steps: {seen:?}" + ); +} + +/// Warm up lazy allocations (thread pool) so that they do not count. +fn warm_up() { + let d = data(8, 20, 5); + let mut s = problem(8, &d).start(settings(1e-8, 100)).unwrap(); + let _ = s.step(3); + drop(s); +} + +/// (3) Drop after 3 steps: memory returns to the level before `start`. +#[test] +fn drop_after_steps_frees_memory() { + let _g = MEM_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let d = data(N, 300, 60); + warm_up(); + let p = problem(N, &d); + let before = common::current(); + let mut s = p.start(settings(1e-10, 5000)).unwrap(); + for _ in 0..3 { + let _ = s.step(7).unwrap(); + } + drop(s); + let after = common::current(); + // The session owns the problem, so the level may be lower. It must not be higher. + assert!( + after <= before + 64 * 1024, + "leak: before {before}, after {after}" + ); +} + +/// (4) Drop right after `start` (cancel during setup): same check. +#[test] +fn drop_after_start_frees_memory() { + let _g = MEM_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let d = data(N, 300, 60); + warm_up(); + let p = problem(N, &d); + let before = common::current(); + let s = p.start(settings(1e-10, 5000)).unwrap(); + drop(s); + let after = common::current(); + assert!( + after <= before + 64 * 1024, + "leak: before {before}, after {after}" + ); +} + +/// (5) `finish()` after 3 steps gives a field and `converged == false`. +/// `best_so_far()` gives the same. +#[test] +fn finish_early_is_not_converged() { + let _g = MEM_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let d = data(N, 300, 60); + let mut s = problem(N, &d).start(settings(1e-10, 5000)).unwrap(); + for _ in 0..3 { + assert!(matches!(s.step(7).unwrap(), Step::Running(_))); + } + let (best, best_report) = s.best_so_far().unwrap(); + assert!(!best_report.converged); + let (field, report) = s.finish().unwrap(); + assert!(!report.converged); + assert_eq!(report.iterations, best_report.iterations); + let a = field.values_f64(); + let b = best.values_f64(); + assert_eq!(a.len(), N * N * N); + assert_eq!(a, b); +} + +/// (6) `max_iter = 3` gives `Done` with `converged == false` and a residual. +#[test] +fn max_iter_stops_not_converged() { + let _g = MEM_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let d = data(N, 300, 60); + let mut s = problem(N, &d).start(settings(1e-10, 3)).unwrap(); + let r = run_to_done(&mut s, 7); + assert!(!r.converged); + assert!(r.iterations <= 3); + assert!(r.residual.is_finite() && r.residual > 0.0); +} + +/// (7) The report has `peak_bytes > 0` and the true count of iterations. +#[test] +fn report_has_peak_and_iterations() { + let _g = MEM_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let d = data(N, 300, 60); + let want = reference(N, &d); + let mut s = problem(N, &d).start(settings(1e-10, 5000)).unwrap(); + let r = run_to_done(&mut s, 7); + assert!(r.peak_bytes > 0); + assert!(r.iterations > 0); + // The same algorithm gives about the same count as the one-shot solve. + let diff = (r.iterations as i64 - want.iterations as i64).abs(); + assert!( + diff <= 5, + "iterations {} and one-shot {}", + r.iterations, + want.iterations + ); + let (_f, r2) = s.finish().unwrap(); + assert_eq!(r2.iterations, r.iterations); +} diff --git a/packages/loop_interpolation_core/tests/warm_start_ridge.rs b/packages/loop_interpolation_core/tests/warm_start_ridge.rs new file mode 100644 index 00000000..ddea9ad6 --- /dev/null +++ b/packages/loop_interpolation_core/tests/warm_start_ridge.rs @@ -0,0 +1,278 @@ +//! Tests of the start value (`set_start`) and the ridge factor +//! (`set_ridge_factor`). A warm start must give the same field as a cold +//! start, and a start at the solution must need almost no iterations. + +use loop_interpolation_core::{CoreError, Field, Precision, Problem, Settings, SolveReport, Step}; + +const N: usize = 20; +/// A bound that never acts. +const BIG: f64 = 1.0e6; + +/// Index of the node (i, j, k) in the field values. +fn idx(i: usize, j: usize, k: usize) -> usize { + i + N * (j + N * k) +} + +/// Node (i, j, k) as a point. The grid origin is 0 and the step is 1. +fn pt(i: usize, j: usize, k: usize) -> [f64; 3] { + [i as f64, j as f64, k as f64] +} + +/// Problem with data that rise along x: value = x on a set of nodes. +fn base_problem() -> Problem { + let mut p = Problem::new([0.0; 3], [N, N, N], [1.0; 3]).unwrap(); + let mut points = Vec::new(); + let mut values = Vec::new(); + let mut weights = Vec::new(); + for i in 2..=17 { + for &j in &[3usize, 9, 16] { + for &k in &[3usize, 9, 16] { + points.extend_from_slice(&pt(i, j, k)); + values.push(i as f64); + weights.push(1.0); + } + } + } + p.add_value(&points, &values, &weights).unwrap(); + p +} + +/// Problem with an active upper bound, so that the session uses ADMM. +fn bound_problem() -> Problem { + let mut p = base_problem(); + p.add_value_bound(&pt(15, 9, 9), &[-BIG], &[10.0], &[1.0]) + .unwrap(); + p +} + +fn settings() -> Settings { + Settings { + tol: 1e-10, + max_iter: 20_000, + ..Settings::default() + } +} + +fn run(p: Problem, s: Settings) -> (Field, SolveReport) { + let mut session = p.start(s).unwrap(); + for _ in 0..1_000_000 { + if let Step::Done(_) = session.step(50).unwrap() { + return session.finish().unwrap(); + } + } + panic!("the session does not finish"); +} + +fn range_of(v: &[f64]) -> f64 { + let lo = v.iter().cloned().fold(f64::INFINITY, f64::min); + let hi = v.iter().cloned().fold(f64::NEG_INFINITY, f64::max); + hi - lo +} + +fn max_diff(a: &[f64], b: &[f64]) -> f64 { + a.iter() + .zip(b.iter()) + .map(|(x, y)| (x - y).abs()) + .fold(0.0, f64::max) +} + +/// A start value that is far from the solution, but not zero. +fn rough_start() -> Vec { + (0..N * N * N) + .map(|n| 3.0 * ((n % 7) as f64) - 5.0) + .collect() +} + +#[test] +fn cg_warm_start_at_the_solution_needs_few_iterations() { + let (cold, cold_report) = run(base_problem(), settings()); + assert!(cold_report.converged); + let mut p = base_problem(); + p.set_start(&cold.values_f64()).unwrap(); + let (warm, warm_report) = run(p, settings()); + assert!(warm_report.converged); + println!( + "start at the solution: {} iterations, cold: {}", + warm_report.iterations, cold_report.iterations + ); + // The recursive residual of the cold solve is below the tolerance. The + // true residual of the same field can be a little above it, so a few + // iterations are allowed. + assert!( + warm_report.iterations <= 3, + "warm {} cold {}", + warm_report.iterations, + cold_report.iterations + ); + let cold = cold.values_f64(); + assert!(max_diff(&warm.values_f64(), &cold) <= 1e-6 * range_of(&cold)); +} + +/// Data for the nested test: value = a smooth function, at points inside +/// [1, 18]^3 (physical units, the same for both grids). +fn add_nested_data(p: &mut Problem) { + let mut points = Vec::new(); + let mut values = Vec::new(); + for a in 0..6 { + for b in 0..6 { + for c in 0..4 { + let q = [ + 1.0 + 3.2 * a as f64, + 1.5 + 3.1 * b as f64, + 2.0 + 4.7 * c as f64, + ]; + values.push(0.1 * q[0] + 0.05 * q[1] * q[1] / 18.0 + 0.2 * (q[2] * 0.3).sin()); + points.extend_from_slice(&q); + } + } + } + let weights = vec![1.0; values.len()]; + p.add_value(&points, &values, &weights).unwrap(); +} + +/// Nested iteration: solve on 10^3, evaluate that field at the nodes of a +/// 20^3 grid with the same extent, and use it as the start of the 20^3 +/// solve. The result equals a cold 20^3 solve within SC-003 (1e-6 of the +/// field range). +#[test] +fn nested_10_to_20_equals_a_cold_solve() { + const EXTENT: f64 = 19.0; + let mut coarse = Problem::new([0.0; 3], [10; 3], [EXTENT / 9.0; 3]).unwrap(); + add_nested_data(&mut coarse); + let (coarse_field, coarse_report) = run(coarse, settings()); + assert!(coarse_report.converged); + + // Fine nodes in the node order of the grid (x fastest). + let mut nodes = Vec::with_capacity(3 * 20 * 20 * 20); + for k in 0..20 { + for j in 0..20 { + for i in 0..20 { + nodes.extend_from_slice(&[i as f64, j as f64, k as f64]); + } + } + } + let start = coarse_field.evaluate(&nodes).unwrap(); + + let mut cold_problem = Problem::new([0.0; 3], [20; 3], [1.0; 3]).unwrap(); + add_nested_data(&mut cold_problem); + let (cold, cold_report) = run(cold_problem, settings()); + assert!(cold_report.converged); + + let mut nested_problem = Problem::new([0.0; 3], [20; 3], [1.0; 3]).unwrap(); + add_nested_data(&mut nested_problem); + nested_problem.set_start(&start).unwrap(); + let (nested, nested_report) = run(nested_problem, settings()); + assert!(nested_report.converged); + println!( + "nested: {} iterations (+{} on 10^3), cold: {}", + nested_report.iterations, coarse_report.iterations, cold_report.iterations + ); + + let cold = cold.values_f64(); + let diff = max_diff(&nested.values_f64(), &cold); + assert!(diff <= 1e-6 * range_of(&cold), "difference {diff}"); + assert!(nested_report.iterations <= cold_report.iterations); +} + +#[test] +fn cg_warm_start_from_a_rough_start_gives_the_same_field() { + let (cold, _) = run(base_problem(), settings()); + let mut p = base_problem(); + p.set_start(&rough_start()).unwrap(); + let (warm, report) = run(p, settings()); + assert!(report.converged); + let cold = cold.values_f64(); + let diff = max_diff(&warm.values_f64(), &cold); + assert!(diff <= 1e-6 * range_of(&cold), "difference {diff}"); +} + +#[test] +fn f32_warm_start_at_the_solution_converges() { + let s = Settings { + precision: Precision::F32, + // The f32 residual of this problem stops near 2e-5. + tol: 1e-4, + ..settings() + }; + let (cold, cold_report) = run(base_problem(), s.clone()); + assert!(cold_report.converged, "{cold_report:?}"); + let mut p = base_problem(); + p.set_start(&cold.values_f64()).unwrap(); + let (_, warm_report) = run(p, s); + assert!(warm_report.converged); + assert!(warm_report.iterations <= cold_report.iterations); +} + +#[test] +fn admm_warm_start_gives_the_same_field() { + let (cold, cold_report) = run(bound_problem(), settings()); + assert!(cold_report.converged); + let cold = cold.values_f64(); + let range = range_of(&cold); + for start in [cold.clone(), rough_start()] { + let mut p = bound_problem(); + p.set_start(&start).unwrap(); + let (warm, report) = run(p, settings()); + assert!(report.converged); + let warm = warm.values_f64(); + assert!(warm[idx(15, 9, 9)] <= 10.0 + 1e-4 * range); + // ADMM stops at a tolerance, so the two fields agree less closely. + let diff = max_diff(&warm, &cold); + assert!(diff <= 1e-3 * range, "difference {diff}"); + } +} + +#[test] +fn start_is_checked() { + let mut p = base_problem(); + let short = vec![0.0; N * N * N - 1]; + assert!(matches!( + p.set_start(&short), + Err(CoreError::InvalidInput { .. }) + )); + let mut bad = vec![0.0; N * N * N]; + bad[7] = f64::NAN; + match p.set_start(&bad) { + Err(CoreError::InvalidInput { constraint, .. }) => { + assert_eq!(constraint, "start[7]") + } + other => panic!("expected InvalidInput, got {other:?}"), + } +} + +#[test] +fn start_is_in_the_memory_estimate() { + let s = settings(); + let mut p = base_problem(); + let without = p.estimate_memory(&s).unwrap().total; + p.set_start(&vec![0.0; N * N * N]).unwrap(); + let with = p.estimate_memory(&s).unwrap().total; + assert!(with >= without + 8 * (N * N * N) as u64); +} + +#[test] +fn large_ridge_factor_pulls_the_field_to_zero() { + let norm = |ridge: f64| { + let mut p = base_problem(); + p.set_ridge_factor(ridge).unwrap(); + let (f, r) = run(p, settings()); + assert!(r.converged); + f.values_f64().iter().map(|v| v * v).sum::().sqrt() + }; + let none = norm(0.0); + let strong = norm(1.0); + assert!(none.is_finite()); + assert!(strong < 0.9 * none, "ridge 0: {none}, ridge 1: {strong}"); +} + +#[test] +fn ridge_factor_is_checked() { + let mut p = base_problem(); + for bad in [-1.0, f64::NAN, f64::INFINITY] { + assert!(matches!( + p.set_ridge_factor(bad), + Err(CoreError::InvalidInput { .. }) + )); + } + assert!(p.set_ridge_factor(0.0).is_ok()); +} diff --git a/packages/loop_interpolation_core/tests_web/fixture.spec.js b/packages/loop_interpolation_core/tests_web/fixture.spec.js new file mode 100644 index 00000000..8d972885 --- /dev/null +++ b/packages/loop_interpolation_core/tests_web/fixture.spec.js @@ -0,0 +1,35 @@ +// Story 1, scenarios 1 to 4 and FR-017: the Worker solves the 20^3 fixture +// in Chromium and the field agrees with the reference field of the Python +// matrix solver. The page has NO cross-origin isolation (FR-006). +// @ts-check +const { test, expect } = require('@playwright/test'); + +test('worker solves the 20^3 fixture and agrees with the reference', async ({ page }) => { + const errors = []; + page.on('pageerror', (e) => errors.push(String(e))); + + const response = await page.goto('/tests_web/harness.html'); + expect(response && response.ok()).toBeTruthy(); + + // FR-006: no COOP/COEP headers, so the page is not cross-origin isolated. + const headers = response ? response.headers() : {}; + expect(headers['cross-origin-opener-policy']).toBeUndefined(); + expect(headers['cross-origin-embedder-policy']).toBeUndefined(); + expect(await page.evaluate(() => crossOriginIsolated)).toBe(false); + + await page.waitForFunction(() => window.harnessReady === true); + + // Fetch the fixture in the page and give the bytes to runFixture. + const outcome = await page.evaluate(async () => { + const res = await fetch('/fixtures/fixture_20.bin'); + if (!res.ok) throw new Error('cannot fetch the fixture: ' + res.status); + const bytes = await res.arrayBuffer(); + return await window.runFixture(bytes); + }); + + expect(outcome.length).toBe(20 * 20 * 20); + expect(outcome.range).toBeGreaterThan(0); + // Maximum difference: at most 1e-6 of the range of the reference field. + expect(outcome.maxDiff).toBeLessThanOrEqual(1e-6 * outcome.range); + expect(errors).toEqual([]); +}); diff --git a/packages/loop_interpolation_core/tests_web/harness.html b/packages/loop_interpolation_core/tests_web/harness.html new file mode 100644 index 00000000..21429eb8 --- /dev/null +++ b/packages/loop_interpolation_core/tests_web/harness.html @@ -0,0 +1,178 @@ + + + + + loop_interpolation_core harness + + + + + + diff --git a/packages/loop_interpolation_core/tests_web/package-lock.json b/packages/loop_interpolation_core/tests_web/package-lock.json new file mode 100644 index 00000000..a15de3e5 --- /dev/null +++ b/packages/loop_interpolation_core/tests_web/package-lock.json @@ -0,0 +1,93 @@ +{ + "name": "loop-interpolation-core-tests-web", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "loop-interpolation-core-tests-web", + "version": "0.0.0", + "devDependencies": { + "@playwright/test": "1.62.1", + "typescript": "5.9.3" + } + }, + "node_modules/@playwright/test": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/packages/loop_interpolation_core/tests_web/package.json b/packages/loop_interpolation_core/tests_web/package.json new file mode 100644 index 00000000..9cdccf94 --- /dev/null +++ b/packages/loop_interpolation_core/tests_web/package.json @@ -0,0 +1,13 @@ +{ + "name": "loop-interpolation-core-tests-web", + "version": "0.0.0", + "private": true, + "description": "Browser tests (Chromium) for the loop_interpolation_core WebAssembly package.", + "scripts": { + "test": "playwright test" + }, + "devDependencies": { + "@playwright/test": "1.62.1", + "typescript": "5.9.3" + } +} diff --git a/packages/loop_interpolation_core/tests_web/playwright.config.js b/packages/loop_interpolation_core/tests_web/playwright.config.js new file mode 100644 index 00000000..cf2948d9 --- /dev/null +++ b/packages/loop_interpolation_core/tests_web/playwright.config.js @@ -0,0 +1,29 @@ +// Playwright configuration for the browser tests of loop_interpolation_core. +// Chromium only. The server is a plain static server (Python standard +// library). It sends NO cross-origin isolation headers (FR-006): the tests +// must pass without SharedArrayBuffer and without threads. +// @ts-check +const { defineConfig } = require('@playwright/test'); + +const PORT = 4173; + +module.exports = defineConfig({ + testDir: '.', + testMatch: '*.spec.js', + timeout: 120000, + fullyParallel: false, + workers: 1, + reporter: 'list', + use: { + baseURL: `http://127.0.0.1:${PORT}`, + }, + projects: [{ name: 'chromium', use: { browserName: 'chromium' } }], + webServer: { + // The served root is the package root (the folder above tests_web). + // The command runs in the folder of this file. + command: `python -m http.server ${PORT} --bind 127.0.0.1 --directory ..`, + url: `http://127.0.0.1:${PORT}/tests_web/harness.html`, + reuseExistingServer: false, + timeout: 30000, + }, +}); diff --git a/packages/loop_interpolation_core/tests_web/reference.spec.js b/packages/loop_interpolation_core/tests_web/reference.spec.js new file mode 100644 index 00000000..b18a669c --- /dev/null +++ b/packages/loop_interpolation_core/tests_web/reference.spec.js @@ -0,0 +1,171 @@ +// Browser reference run for R1 (SC-001, SC-003, SC-006, SC-007). +// The spec is skipped unless the environment variable RUN_REFERENCE=1 is set. +// +// Keep in step with tests/reference_problems.rs: the function `build` and the +// LCG there are the source of the formulas below. If one file changes, +// change the other file in the same way. +// +// R1: grid 100^3, 2000 value rows and 500 gradient rows, plane field +// f = x + 0.5 y + 0.25 z, weights 1, tol 1e-8, max_iter 5000, precision f64. +// The Rust test `r1_write_field` writes the reference field to +// target/r1_field.f64 (little-endian f64, x fastest). The static server +// serves it as /target/r1_field.f64. +// +// R2 (200 active pair bounds) is NOT in this file. The pair-bound path needs +// the free field first. It is not needed for T052. +// +// SC-007 (60 s) is report only. The release check uses the reference machine. +// @ts-check +const { test, expect } = require('@playwright/test'); +const fs = require('fs'); +const path = require('path'); + +const FIELD_FILE = path.join(__dirname, '..', 'target', 'r1_field.f64'); +const N = 100; +const N_VALUE = 2000; +const N_NORMAL = 500; + +// This code runs in the page. It makes the R1 solve message. +// The LCG uses BigInt, to get the same 64-bit wrap as Rust. +function installR1Generator() { + window.makeR1 = (id, n, nValue, nNormal) => { + let state = 12345n; + // Same as Lcg::next in reference_problems.rs. + const rnd = () => { + state = BigInt.asUintN(64, state * 6364136223846793005n + 1442695040888963407n); + return Number(state >> 11n) / 9007199254740992; // 2^53 + }; + const extent = n - 1; + const points = new Float64Array(3 * nValue); + const values = new Float64Array(nValue); + for (let i = 0; i < nValue; i++) { + const q = [rnd() * extent, rnd() * extent, rnd() * extent]; + values[i] = q[0] + 0.5 * q[1] + 0.25 * q[2]; + points.set(q, 3 * i); + } + const gpoints = new Float64Array(3 * nNormal); + const gvec = new Float64Array(3 * nNormal); + for (let i = 0; i < nNormal; i++) { + gpoints.set([rnd() * extent, rnd() * extent, rnd() * extent], 3 * i); + gvec.set([1.0, 0.5, 0.25], 3 * i); + } + const empty = () => new Float64Array(0); + const msg = { + type: 'solve', + id, + grid: { + origin: new Float64Array([0, 0, 0]), + counts: new Uint32Array([n, n, n]), + step: new Float64Array([1, 1, 1]), + }, + // The Rust Problem uses weight 1.0 for all regularisation terms. + regularisation: { dxx: 1, dyy: 1, dzz: 1, dxy: 1, dxz: 1, dyz: 1, dx: 1, dy: 1, dz: 1 }, + constraints: { + value: { points, values, weights: new Float64Array(nValue).fill(1) }, + gradient: { points: gpoints, vectors: gvec, weights: new Float64Array(nNormal).fill(1) }, + normal: { points: empty(), vectors: empty(), weights: empty() }, + tangent: { points: empty(), vectors: empty(), weights: empty() }, + interface: { points: empty(), ids: empty(), weight: 1 }, + }, + settings: { precision: 'f64', tol: 1e-8, maxIter: 5000 }, + }; + const transfer = [msg.grid.origin.buffer, msg.grid.counts.buffer, msg.grid.step.buffer]; + for (const fam of Object.values(msg.constraints)) { + for (const k of Object.keys(fam)) { + if (fam[k] instanceof Float64Array) transfer.push(fam[k].buffer); + } + } + // The first value-row point is a check of the LCG (it must be in [0, 99]^3). + return { msg, transfer, firstPoint: Array.from(points.slice(0, 3)) }; + }; + window.stamps = []; + window.loopHarness.worker.addEventListener('message', (e) => { + window.stamps.push({ t: performance.now(), type: e.data.type, id: e.data.id }); + }); +} + +test.describe('R1 reference run', () => { + test.skip(!process.env.RUN_REFERENCE, 'set RUN_REFERENCE=1 to run the reference problems'); + + test('R1 100^3 solve: memory, field difference, progress', async ({ page }) => { + test.setTimeout(15 * 60 * 1000); + + if (!fs.existsSync(FIELD_FILE)) { + throw new Error( + `Reference field file is missing: ${FIELD_FILE}. Run: cargo test --manifest-path ` + + 'packages/loop_interpolation_core/Cargo.toml --release --test reference_problems ' + + 'r1_write_field -- --ignored --nocapture', + ); + } + expect(fs.statSync(FIELD_FILE).size).toBe(8 * N * N * N); + + await page.goto('/tests_web/harness.html'); + await page.waitForFunction(() => window.harnessReady === true); + await page.evaluate(installR1Generator); + await page.evaluate(() => window.loopHarness.post({ type: 'init' })); + + const start = await page.evaluate(([n, nv, ng]) => { + const { msg, transfer, firstPoint } = window.makeR1(1, n, nv, ng); + window.firstPoint = firstPoint; + const t = performance.now(); + window.loopHarness.post(msg, transfer); + return { t, firstPoint }; + }, [N, N_VALUE, N_NORMAL]); + for (const c of start.firstPoint) { + expect(c).toBeGreaterThanOrEqual(0); + expect(c).toBeLessThanOrEqual(N - 1); + } + + const done = await page.evaluate((ms) => window.loopHarness.waitFor( + (x) => x.id === 1 && (x.type === 'result' || x.type === 'error'), ms).then((x) => { + window.fieldResult = x.field; + return { + type: x.type, kind: x.kind, message: x.message, memoryBytes: x.memoryBytes, + iterations: x.iterations, converged: x.converged, residual: x.residual, + length: x.field ? x.field.length : 0, end: performance.now(), + }; + }), 14 * 60 * 1000); + expect(done.type, JSON.stringify(done)).toBe('result'); + const seconds = (done.end - start.t) / 1000; + expect(done.length).toBe(N * N * N); + + // Compare with the reference field. The page reads it from the static server. + const cmp = await page.evaluate(async () => { + const buf = await (await fetch('/target/r1_field.f64')).arrayBuffer(); + const r = new Float64Array(buf); + const f = window.fieldResult; + let maxDiff = 0; + let lo = Infinity; + let hi = -Infinity; + for (let i = 0; i < r.length; i++) { + maxDiff = Math.max(maxDiff, Math.abs(f[i] - r[i])); + lo = Math.min(lo, r[i]); + hi = Math.max(hi, r[i]); + } + return { maxDiff, range: hi - lo }; + }); + const ratio = cmp.maxDiff / cmp.range; + + const times = await page.evaluate(() => window.stamps + .filter((x) => x.id === 1 && (x.type === 'progress' || x.type === 'result')) + .map((x) => x.t)); + let prev = start.t; + let worst = 0; + for (const t of times) { + worst = Math.max(worst, t - prev); + prev = t; + } + + console.log(`R1 memoryBytes=${done.memoryBytes}`); + console.log(`R1 time_s=${seconds.toFixed(1)} (SC-007 target 60 s, report only)`); + console.log(`R1 iterations=${done.iterations} converged=${done.converged}`); + console.log(`R1 max_diff=${cmp.maxDiff} range=${cmp.range} ratio=${ratio}`); + console.log(`R1 progress messages=${times.length} worst_gap_ms=${worst.toFixed(0)}`); + + expect(done.converged).toBe(true); + expect(done.memoryBytes).toBeLessThanOrEqual(100_000_000); // SC-001 + expect(ratio).toBeLessThanOrEqual(1e-6); // SC-003 + expect(times.length).toBeGreaterThan(1); + expect(worst).toBeLessThan(1000); // SC-006 + }); +}); diff --git a/packages/loop_interpolation_core/tests_web/responsiveness.spec.js b/packages/loop_interpolation_core/tests_web/responsiveness.spec.js new file mode 100644 index 00000000..223dd849 --- /dev/null +++ b/packages/loop_interpolation_core/tests_web/responsiveness.spec.js @@ -0,0 +1,221 @@ +// Story 3, story 4, SC-006, FR-010: the page stays responsive while the +// Worker solves a 60^3 problem. The tests also check cancel, the budget +// error, the refusal of a second solve, memoryBytes and progress messages. +// +// Message shapes that these tests expect (T041 and T042 must follow them): +// in : { type:'init' } +// in : { type:'solve', id, grid:{origin,counts,step}, regularisation:{...}, +// constraints:{value,gradient,normal,tangent,interface}, +// settings:{precision,tol,maxIter,budgetBytes?} } +// in : { type:'estimate', id, grid, constraints, settings } +// D6 and T042 do not fix the reply. This file chooses the reply +// { type:'result', id, estimate:{total,vectors,constraints,working} }. +// in : { type:'cancel', id? } +// out: { type:'progress', id, ... } +// out: { type:'result', id, field, memoryBytes, ... } +// out: { type:'cancelled', id } +// out: { type:'error', id, kind, ... } +// kind 'OverBudget' also has estimate, budget, maxNodesPerAxis. +// kind 'SolveRunning' answers a second solve while one runs. +// @ts-check +const { test, expect } = require('@playwright/test'); + +// This code runs in the page. It makes a flat random problem like +// tests/reference_problems.rs: an LCG and a plane field f = a . x. +// It sets window.makeSolve(id, n, rows, settings) -> { msg, transfer }. +function installGenerator() { + window.makeSolve = (id, n, rows, settings) => { + let state = 12345; + // LCG: returns a number in [0, 1). + const rnd = () => { + state = (Math.imul(state, 1664525) + 1013904223) >>> 0; + return state / 4294967296; + }; + const a = [0.7, -0.4, 0.5]; + const points = new Float64Array(3 * rows); + const values = new Float64Array(rows); + const gpoints = new Float64Array(3 * rows); + const gvec = new Float64Array(3 * rows); + for (let i = 0; i < rows; i++) { + for (let k = 0; k < 3; k++) { + points[3 * i + k] = 1 + rnd() * (n - 3); + gpoints[3 * i + k] = 1 + rnd() * (n - 3); + gvec[3 * i + k] = a[k]; + } + values[i] = a[0] * points[3 * i] + a[1] * points[3 * i + 1] + a[2] * points[3 * i + 2]; + } + const empty = () => new Float64Array(0); + const msg = { + type: 'solve', + id, + grid: { + origin: new Float64Array([0, 0, 0]), + counts: new Uint32Array([n, n, n]), + step: new Float64Array([1, 1, 1]), + }, + regularisation: { dxx: 1, dyy: 1, dzz: 1, dxy: 1, dxz: 1, dyz: 1, dx: 0.1, dy: 0.1, dz: 0.1 }, + constraints: { + value: { points, values, weights: new Float64Array(rows).fill(1) }, + gradient: { points: gpoints, vectors: gvec, weights: new Float64Array(rows).fill(1) }, + normal: { points: empty(), vectors: empty(), weights: empty() }, + tangent: { points: empty(), vectors: empty(), weights: empty() }, + interface: { points: empty(), ids: empty(), weight: 1 }, + }, + settings: Object.assign({ precision: 'f64', tol: 1e-8, maxIter: 50000 }, settings || {}), + }; + // The buffers go to the worker without a copy. + const transfer = []; + transfer.push(msg.grid.origin.buffer, msg.grid.counts.buffer, msg.grid.step.buffer); + for (const fam of Object.values(msg.constraints)) { + for (const k of Object.keys(fam)) { + if (fam[k] instanceof Float64Array) transfer.push(fam[k].buffer); + } + } + return { msg, transfer }; + }; + // Record the arrival time of each worker message. + window.stamps = []; + window.loopHarness.worker.addEventListener('message', (e) => { + window.stamps.push({ t: performance.now(), type: e.data.type, id: e.data.id, kind: e.data.kind }); + }); + // Main-thread timer: the largest gap between two ticks, every 10 ms. + window.gapMax = 0; + window.gapLast = 0; + window.startTimer = () => { + window.gapMax = 0; + window.gapLast = performance.now(); + window.timerHandle = setInterval(() => { + const now = performance.now(); + window.gapMax = Math.max(window.gapMax, now - window.gapLast); + window.gapLast = now; + }, 10); + }; + window.stopTimer = () => clearInterval(window.timerHandle); +} + +// Post a generated solve message from the page. +async function postSolve(page, id, n, rows, settings) { + await page.evaluate(([i, nn, r, s]) => { + const { msg, transfer } = window.makeSolve(i, nn, r, s); + window.loopHarness.post(msg, transfer); + }, [id, n, rows, settings || null]); +} + +// Wait for a worker message with a given id and one of the given types. +// A load error of the worker also ends the wait, so a missing worker fails fast. +function waitType(page, id, types, ms) { + return page.evaluate(([i, t, m]) => window.loopHarness.waitFor( + (x) => (x.id === i && t.includes(x.type)) || x.kind === 'WorkerLoadError', m).then((x) => ({ + type: x.type, id: x.id, kind: x.kind, estimate: x.estimate, budget: x.budget, + maxNodesPerAxis: x.maxNodesPerAxis, memoryBytes: x.memoryBytes, + hasField: !!x.field, message: x.message, + })), [id, types, ms]); +} + +test.beforeEach(async ({ page }) => { + await page.goto('/tests_web/harness.html'); + await page.waitForFunction(() => window.harnessReady === true); + await page.evaluate(installGenerator); + await page.evaluate(() => window.loopHarness.post({ type: 'init' })); +}); + +const N = 60; +const ROWS = 2000; + +test('(a) the main thread has no gap above 100 ms during a 60^3 solve', async ({ page }) => { + await page.evaluate(() => window.startTimer()); + await postSolve(page, 1, N, ROWS); + const done = await waitType(page, 1, ['result', 'error'], 110000); + await page.evaluate(() => window.stopTimer()); + expect(done.type).toBe('result'); + const gap = await page.evaluate(() => window.gapMax); + expect(gap).toBeLessThan(100); +}); + +test('(b) cancel after 1 s is answered within 1 s and a new solve works', async ({ page }) => { + await postSolve(page, 1, N, ROWS); + await page.waitForTimeout(1000); + const t0 = await page.evaluate(() => { + window.loopHarness.post({ type: 'cancel', id: 1 }); + return performance.now(); + }); + const cancelled = await waitType(page, 1, ['cancelled', 'result', 'error'], 5000); + const t1 = await page.evaluate(() => { + const s = window.stamps.filter((x) => x.id === 1 && x.type === 'cancelled'); + return s.length ? s[s.length - 1].t : -1; + }); + expect(cancelled.type).toBe('cancelled'); + expect(t1 - t0).toBeLessThan(1000); + + // A new solve (small grid) must work after the cancel. + await postSolve(page, 2, 12, 100); + const done = await waitType(page, 2, ['result', 'error'], 60000); + expect(done.type).toBe('result'); +}); + +test('(c) a budget of 1 MB gives an OverBudget error', async ({ page }) => { + await postSolve(page, 1, N, ROWS, { budgetBytes: 1e6 }); + const err = await waitType(page, 1, ['error', 'result'], 30000); + expect(err.type).toBe('error'); + expect(err.kind).toBe('OverBudget'); + expect(err.estimate).toBeTruthy(); + expect(typeof err.estimate.total).toBe('number'); + expect(err.budget).toBe(1e6); + expect(typeof err.maxNodesPerAxis).toBe('number'); + expect(err.maxNodesPerAxis).toBeGreaterThan(0); +}); + +test('estimate message gives the estimate in a result message', async ({ page }) => { + await page.evaluate(([n, r]) => { + const { msg, transfer } = window.makeSolve(1, n, r); + msg.type = 'estimate'; + window.loopHarness.post(msg, transfer); + }, [N, ROWS]); + const res = await page.evaluate(() => window.loopHarness.waitFor( + (x) => x.id === 1 && (x.type === 'result' || x.type === 'error'), 30000) + .then((x) => ({ type: x.type, estimate: x.estimate }))); + expect(res.type).toBe('result'); + for (const key of ['total', 'vectors', 'constraints', 'working']) { + expect(typeof res.estimate[key]).toBe('number'); + } + expect(res.estimate.total).toBeGreaterThan(0); +}); + +test('(d) a second solve while one runs gives SolveRunning; the first ends normally', async ({ page }) => { + await postSolve(page, 1, N, ROWS); + // Wait until the first solve has started (first progress message). + await waitType(page, 1, ['progress', 'result', 'error'], 30000); + await postSolve(page, 2, 12, 100); + const refused = await waitType(page, 2, ['error', 'result'], 30000); + expect(refused.type).toBe('error'); + expect(refused.kind).toBe('SolveRunning'); + const first = await waitType(page, 1, ['result', 'error', 'cancelled'], 110000); + expect(first.type).toBe('result'); +}); + +test('(e) the result carries memoryBytes', async ({ page }) => { + await postSolve(page, 1, 20, 300); + const done = await waitType(page, 1, ['result', 'error'], 60000); + expect(done.type).toBe('result'); + expect(typeof done.memoryBytes).toBe('number'); + expect(done.memoryBytes).toBeGreaterThan(0); +}); + +test('(f) progress messages arrive at least once each second', async ({ page }) => { + const t0 = await page.evaluate(() => performance.now()); + await postSolve(page, 1, N, ROWS); + const done = await waitType(page, 1, ['result', 'error'], 110000); + expect(done.type).toBe('result'); + const times = await page.evaluate(() => window.stamps + .filter((x) => x.id === 1 && (x.type === 'progress' || x.type === 'result')) + .map((x) => x.t)); + expect(times.length).toBeGreaterThan(1); + // Gaps: start to first progress, between progress messages, last to result. + let prev = t0; + let worst = 0; + for (const t of times) { + worst = Math.max(worst, t - prev); + prev = t; + } + expect(worst).toBeLessThan(1000); +}); diff --git a/packages/loop_interpolation_core/web/README.md b/packages/loop_interpolation_core/web/README.md new file mode 100644 index 00000000..0cb439b0 --- /dev/null +++ b/packages/loop_interpolation_core/web/README.md @@ -0,0 +1,136 @@ +# loop_interpolation_core: browser package + +This folder contains the Web Worker that runs the solver. The solver is +WebAssembly. The worker file is `worker.js`. The type declarations are in +`index.d.ts`. + +## Files + +| File | Content | +|---|---| +| `web/worker.js` | The Worker. It loads the WebAssembly module and handles the messages. | +| `web/index.d.ts` | The TypeScript types of the messages and of the WebAssembly classes. | +| `pkg/` | The WebAssembly module and its JavaScript glue. The build makes it. | + +The worker loads `../pkg/`. Serve `web/` and `pkg/` from the same origin. + +## Build + +1. Install the Rust target: `rustup target add wasm32-unknown-unknown`. +2. Install the wasm-bindgen CLI. Its version must equal the `wasm-bindgen` + version in `Cargo.lock`. +3. From the package folder, run: + + ```sh + bash scripts/build_wasm.sh + ``` + +The script makes `pkg/`. You do not need `wasm-opt`. If `wasm-opt` is +installed, the script uses it. If not, the script skips it. + +## Load the worker + +```js +const worker = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' }); +worker.postMessage({ type: 'init' }); +``` + +The main thread does not load the WebAssembly module. Only the worker loads it. + +## No cross-origin isolation + +The package uses no threads and no `SharedArrayBuffer`. Your page does not +need the headers `Cross-Origin-Opener-Policy` and `Cross-Origin-Embedder-Policy`. + +## Coordinates: transform them first + +The solver takes grid coordinates only. It does not rotate or move the data +(spec FR-020). Your page must change all points and vectors into grid +coordinates before it sends them. Do this for any rotation of the model. + +## Messages + +Messages to the worker: + +| `type` | Purpose | Reply | +|---|---|---| +| `init` | Start the module load. | None. | +| `estimate` | Get the memory estimate. | `result` with `estimate`. | +| `solve` | Start a solve. | `progress` messages, then `result` or `error`. | +| `cancel` | Stop a solve. `id` is optional. | `cancelled`. | + +Messages from the worker: + +| `type` | Content | +|---|---| +| `progress` | `id`, `stage`, `iteration`, `residual`, `maxViolation`. | +| `result` | Solve: `field`, `memoryBytes`, `done`, `converged`, `peakBytes`, `iterations`, `residual`, `maxViolation`, `warnings`. Estimate: `estimate`. | +| `cancelled` | `id`. | +| `error` | `id` and `kind`. Other fields depend on the kind. | + +Only one solve runs at a time. A second `solve` or `estimate` gets the error +kind `SolveRunning`. + +### Example: solve + +```js +worker.postMessage( + { + type: 'solve', + id: 1, + grid: { + origin: new Float64Array([0, 0, 0]), + counts: new Uint32Array([20, 20, 20]), + step: new Float64Array([1, 1, 1]), + }, + regularisation: { dxx: 1, dyy: 1, dzz: 1 }, + constraints: { + value: { points, values, weights }, + }, + settings: { precision: 'f64', tol: 1e-8, maxIter: 50000 }, + }, + [points.buffer, values.buffer, weights.buffer], +); +``` + +`points`, `values` and `weights` are `Float64Array` objects in grid +coordinates. The transfer list moves the buffers to the worker without a copy. +After the transfer, the page cannot use these arrays. + +### Example: cancel + +```js +worker.postMessage({ type: 'cancel', id: 1 }); +``` + +The worker stops the solve at the next step. It then sends `cancelled` with +the same `id`. + +### Error kinds + +| `kind` | Meaning | +|---|---| +| `OverBudget` | The estimate is above `budgetBytes`. The message has `estimate`, `budget` and `maxNodesPerAxis`. | +| `SolveRunning` | A solve already runs. | +| `InvalidInput` | A value is wrong. The message has `constraint` and `reason`. | +| `GridTooSmall` | An axis has too few nodes. | +| `GridTooLarge` | The grid has too many nodes. | +| `NoConstraints` | The message has no constraints. | +| `OutOfMemory` | The solver could not get memory. | +| `Infeasible` | The constraints cannot all be met. The message has `worst`. | +| `PointOutsideGrid` | A point is outside the grid. | +| `Unsupported` | A setting is not supported. | +| `Panic` | The WebAssembly module stopped with a trap. The worker loads it again. | +| `WorkerLoadError` | The module did not load. | +| `InternalError` | Another error. The message has `message`. | + +## Types + +Import the types in TypeScript: + +```ts +import type { WorkerInMessage, WorkerOutMessage } from './index.js'; +``` + +The file `index.d.ts` also declares the WebAssembly classes `Problem`, +`Session`, `Estimate` and `StepResult`, and the function `memoryBytes`. diff --git a/packages/loop_interpolation_core/web/index.d.ts b/packages/loop_interpolation_core/web/index.d.ts new file mode 100644 index 00000000..dc1fca2f --- /dev/null +++ b/packages/loop_interpolation_core/web/index.d.ts @@ -0,0 +1,311 @@ +// Type declarations for the browser package of loop_interpolation_core. +// +// Two parts: +// 1. The messages of web/worker.js (task T042, plan D6). +// 2. The classes of the wasm package pkg/ (made by wasm-bindgen, see +// src/wasm.rs for the JavaScript signatures). +// +// Coordinates: every point and vector is in grid coordinates. The solver +// does not rotate or move the data (spec FR-020). The caller does that first. + +// --------------------------------------------------------------------------- +// Shared types +// --------------------------------------------------------------------------- + +/** Identifies one request. The worker copies it into each reply. */ +export type MessageId = number | string; + +/** Solver precision. 'f32' stores the field in single precision. */ +export type Precision = 'f64' | 'f32'; + +/** Grid definition. Counts are node counts per axis. */ +export interface Grid { + origin: Float64Array; + counts: Uint32Array; + step: Float64Array; +} + +/** + * Settings of estimate and solve. precision, tol and maxIter are always + * given in a message. The other keys are optional. + */ +export interface Settings { + precision: Precision; + tol: number; + maxIter: number; + budgetBytes?: number; + rho?: number; + innerMaxIter?: number; + innerTol?: number; + admmAbsTol?: number; + admmRelTol?: number; +} + +/** Regularisation weights. A missing key means the default weight 1.0. */ +export interface Regularisation { + dxx?: number; + dyy?: number; + dzz?: number; + dxy?: number; + dxz?: number; + dyz?: number; + /** Border weights. */ + dx?: number; + dy?: number; + dz?: number; +} + +/** Value constraints: the field equals values at points. */ +export interface ValueConstraints { + points: Float64Array; + values: Float64Array; + weights: Float64Array; +} + +/** Gradient constraints: the gradient of the field equals vectors at points. */ +export interface GradientConstraints { + points: Float64Array; + vectors: Float64Array; + weights: Float64Array; +} + +/** Normal constraints: the gradient is parallel to vectors at points. */ +export interface NormalConstraints { + points: Float64Array; + vectors: Float64Array; + weights: Float64Array; +} + +/** Tangent constraints: the gradient is orthogonal to vectors at points. */ +export interface TangentConstraints { + points: Float64Array; + vectors: Float64Array; + weights: Float64Array; +} + +/** Interface constraints: points with the same id lie on one surface. */ +export interface InterfaceConstraints { + points: Float64Array; + ids: Float64Array; + weight: number; +} + +/** Inequality bounds on the value at points. */ +export interface ValueBoundConstraints { + points: Float64Array; + lower: Float64Array; + upper: Float64Array; + weights: Float64Array; +} + +/** Inequality bounds on the difference of values at two points. */ +export interface PairBoundConstraints { + pointsA: Float64Array; + pointsB: Float64Array; + lower: Float64Array; + upper: Float64Array; + weights: Float64Array; +} + +/** All constraint families. A missing family adds no constraints. */ +export interface Constraints { + value?: ValueConstraints; + gradient?: GradientConstraints; + normal?: NormalConstraints; + tangent?: TangentConstraints; + interface?: InterfaceConstraints; + valueBound?: ValueBoundConstraints; + pairBound?: PairBoundConstraints; +} + +/** Memory estimate in bytes, as the worker posts it (a plain object). */ +export interface MemoryEstimate { + total: number; + vectors: number; + constraints: number; + working: number; +} + +// --------------------------------------------------------------------------- +// Messages in (page -> worker) +// --------------------------------------------------------------------------- + +/** Starts the module load. The worker sends no reply. */ +export interface InitMessage { + type: 'init'; +} + +/** Asks for the memory estimate. The reply is a ResultMessage with estimate. */ +export interface EstimateMessage { + type: 'estimate'; + id: MessageId; + grid: Grid; + constraints?: Constraints; + settings: Settings; +} + +/** Starts a solve. The reply is progress messages, then a result or an error. */ +export interface SolveMessage { + type: 'solve'; + id: MessageId; + grid: Grid; + regularisation?: Regularisation; + constraints?: Constraints; + settings: Settings; +} + +/** + * Cancels a solve. Without id, the running solve is cancelled. The worker + * answers with 'cancelled'. If no solve runs, it answers with 'cancelled' too. + */ +export interface CancelMessage { + type: 'cancel'; + id?: MessageId; +} + +export type WorkerInMessage = InitMessage | EstimateMessage | SolveMessage | CancelMessage; + +// --------------------------------------------------------------------------- +// Messages out (worker -> page) +// --------------------------------------------------------------------------- + +/** Solve progress. Sent after the first step and then at most every 100 ms. */ +export interface ProgressMessage { + type: 'progress'; + id: MessageId; + stage: 'setup' | 'solve' | 'check'; + iteration: number; + residual: number; + maxViolation: number; +} + +/** Result of a finished solve. The field buffer is transferred, not copied. */ +export interface SolveResult { + type: 'result'; + id: MessageId; + field: Float64Array | Float32Array; + memoryBytes: number; + done: true; + converged: boolean; + peakBytes: number; + iterations: number; + residual: number; + maxViolation: number; + warnings: string[]; +} + +/** Result of an estimate message. */ +export interface EstimateResult { + type: 'result'; + id: MessageId; + estimate: MemoryEstimate; +} + +export type ResultMessage = SolveResult | EstimateResult; + +/** Confirms a cancel. */ +export interface CancelledMessage { + type: 'cancelled'; + id?: MessageId; +} + +/** Error body. JSON numbers that are not finite arrive as null. */ +export type ErrorBody = + | { kind: 'OverBudget'; estimate: MemoryEstimate; budget: number; maxNodesPerAxis: number } + | { kind: 'SolveRunning' } + | { kind: 'InvalidInput'; constraint: string; reason: string } + | { kind: 'GridTooSmall'; axis: number; nodes: number } + | { kind: 'GridTooLarge'; nodes: number; maxNodes: number } + | { kind: 'NoConstraints' } + | { kind: 'OutOfMemory'; requestedBytes: number } + | { + kind: 'Infeasible'; + worst: Array<{ index: number; kind: 'value' | 'pair'; violation: number | null }>; + } + | { kind: 'PointOutsideGrid'; index: number } + | { kind: 'Unsupported'; setting: string } + | { kind: 'Panic'; message: string } + | { kind: 'WorkerLoadError'; message: string } + | { kind: 'InternalError'; message: string }; + +/** Error kinds. */ +export type ErrorKind = ErrorBody['kind']; + +/** Error reply. id is missing when the error is not tied to a request. */ +export type ErrorMessage = { type: 'error'; id?: MessageId } & ErrorBody; + +export type WorkerOutMessage = + | ProgressMessage + | ResultMessage + | CancelledMessage + | ErrorMessage; + +// --------------------------------------------------------------------------- +// Classes of the wasm package (pkg/loop_interpolation_core.js) +// --------------------------------------------------------------------------- + +/** Memory estimate with a free() method (the wasm object). */ +export declare class Estimate { + readonly total: number; + readonly vectors: number; + readonly constraints: number; + readonly working: number; + free(): void; +} + +/** Result of one Session.step call. converged, peakBytes and warnings are valid when done is true. */ +export declare class StepResult { + readonly done: boolean; + readonly stage: 'setup' | 'solve' | 'check' | 'done'; + readonly iteration: number; + readonly residual: number; + readonly violation: number; + readonly converged: boolean; + readonly peakBytes: number; + readonly warnings: string[]; + free(): void; +} + +/** An interpolation problem. */ +export declare class Problem { + constructor(origin: Float64Array, counts: Uint32Array, step: Float64Array); + setRegularisation(weights: Regularisation): void; + addValue(points: Float64Array, values: Float64Array, weights: Float64Array): void; + addGradient(points: Float64Array, vectors: Float64Array, weights: Float64Array): void; + addNormal(points: Float64Array, vectors: Float64Array, weights: Float64Array): void; + addTangent(points: Float64Array, vectors: Float64Array, weights: Float64Array): void; + addInterface(points: Float64Array, ids: Float64Array, weight: number): void; + addValueBound( + points: Float64Array, + lower: Float64Array, + upper: Float64Array, + weights: Float64Array, + ): void; + addPairBound( + pointsA: Float64Array, + pointsB: Float64Array, + lower: Float64Array, + upper: Float64Array, + weights: Float64Array, + ): void; + /** Memory estimate. Throws a JsError whose message is JSON with a kind key. */ + estimate(settings?: Partial): Estimate; + /** Starts a solve. This consumes the problem. Do not use it again. */ + start(settings?: Partial): Session; + free(): void; +} + +/** A running solve. */ +export declare class Session { + /** Runs at most maxIters iterations. After done, it keeps returning done. */ + step(maxIters: number): StepResult; + /** Copies the field out. Before the end, it is the best result so far. */ + field(): Float64Array | Float32Array; + evaluate(points: Float64Array): Float64Array; + evaluateGradient(points: Float64Array): Float64Array; + /** Drops the session. */ + free(): void; +} + +/** Size of the wasm linear memory in bytes. The memory never shrinks. */ +export declare function memoryBytes(): number; diff --git a/packages/loop_interpolation_core/web/worker.js b/packages/loop_interpolation_core/web/worker.js new file mode 100644 index 00000000..8ba7db00 --- /dev/null +++ b/packages/loop_interpolation_core/web/worker.js @@ -0,0 +1,403 @@ +// Worker wrapper for loop_interpolation_core (task T042, plan D6). +// +// This file is a module Worker. It loads the WebAssembly package from +// ../pkg/ (made by wasm-bindgen --target web). The main thread never loads +// the module. +// +// Messages in: +// { type:'init' } +// { type:'estimate', id, grid, constraints, settings } +// { type:'solve', id, grid, regularisation, constraints, settings } +// { type:'cancel', id? } +// Messages out: +// { type:'progress', id, stage, iteration, residual, maxViolation } +// { type:'result', id, field, memoryBytes, done, converged, peakBytes, +// iterations, residual, warnings } +// { type:'result', id, estimate:{ total, vectors, constraints, working } } +// { type:'cancelled', id } +// { type:'error', id, kind, ... } +// +// Only one solve runs at a time. A second solve or estimate gets the error +// kind 'SolveRunning'. + +// Time for one step() call, in milliseconds. +const TARGET_STEP_MS = 100; +// Smallest time between two progress messages, in milliseconds. +const PROGRESS_MIN_MS = 100; +// Limits of the iteration count for one step() call. +const MIN_ITERS = 1; +const MAX_ITERS = 5000; +const START_ITERS = 5; + +// State of the module loader. +let wasmApi = null; // the imported module, or null when it is not loaded +let loadPromise = null; // Promise while the module loads +let needsReinit = false; // true after a WebAssembly trap +let loadCount = 0; // makes a new module URL for each re-instantiation + +// State of the solve. +let solving = false; +let cancelFlag = false; +let currentId = undefined; + +// Messages that arrive while the module loads. +const queue = []; +let draining = false; + +// Load (or load again) the WebAssembly module. +function ensureLoaded() { + if (wasmApi && !needsReinit) return Promise.resolve(wasmApi); + if (loadPromise) return loadPromise; + const suffix = loadCount === 0 ? '' : `?reload=${loadCount}`; + loadCount += 1; + loadPromise = (async () => { + // A new query string gives a new module instance, with new memory. + const mod = await import(`../pkg/loop_interpolation_core.js${suffix}`); + await mod.default(); + wasmApi = mod; + needsReinit = false; + return mod; + })(); + loadPromise = loadPromise.then( + (mod) => { + loadPromise = null; + return mod; + }, + (err) => { + loadPromise = null; + wasmApi = null; + throw err; + }, + ); + return loadPromise; +} + +// Turn a thrown value into the fields of an error message. +function describeError(err) { + const text = err && err.message !== undefined ? String(err.message) : String(err); + // A WebAssembly trap (panic=abort) shows as 'unreachable'. + const isTrap = + (typeof WebAssembly !== 'undefined' && + WebAssembly.RuntimeError && + err instanceof WebAssembly.RuntimeError) || + /unreachable/i.test(text); + if (isTrap) { + return { trap: true, fields: { kind: 'Panic', message: text } }; + } + try { + const parsed = JSON.parse(text); + if (parsed && typeof parsed === 'object' && typeof parsed.kind === 'string') { + return { trap: false, fields: parsed }; + } + } catch (_e) { + // The text is not JSON. Use the fallback below. + } + return { trap: false, fields: { kind: 'InternalError', message: text } }; +} + +// Post an error message for a thrown value. +// "extra" can replace fields (for example the estimate of OverBudget). +function postError(id, err, extra) { + const info = describeError(err); + if (info.trap) { + // The module state is not safe now. Load it again for the next message. + needsReinit = true; + wasmApi = null; + } + const fields = Object.assign({}, info.fields, extra || {}); + fields.type = 'error'; + fields.id = id; + self.postMessage(fields); +} + +function toF64(a) { + if (a instanceof Float64Array) return a; + return Float64Array.from(a || []); +} + +// Add all constraints of a message to the problem. +function addConstraints(problem, c) { + if (!c) return; + const has = (fam, key) => fam && fam[key] && fam[key].length > 0; + if (has(c.value, 'points')) { + problem.addValue(toF64(c.value.points), toF64(c.value.values), toF64(c.value.weights)); + } + if (has(c.gradient, 'points')) { + problem.addGradient(toF64(c.gradient.points), toF64(c.gradient.vectors), toF64(c.gradient.weights)); + } + if (has(c.normal, 'points')) { + problem.addNormal(toF64(c.normal.points), toF64(c.normal.vectors), toF64(c.normal.weights)); + } + if (has(c.tangent, 'points')) { + problem.addTangent(toF64(c.tangent.points), toF64(c.tangent.vectors), toF64(c.tangent.weights)); + } + if (has(c.interface, 'points')) { + problem.addInterface(toF64(c.interface.points), toF64(c.interface.ids), Number(c.interface.weight)); + } + if (has(c.valueBound, 'points')) { + const b = c.valueBound; + problem.addValueBound(toF64(b.points), toF64(b.lower), toF64(b.upper), toF64(b.weights)); + } + if (has(c.pairBound, 'pointsA')) { + const b = c.pairBound; + problem.addPairBound( + toF64(b.pointsA), toF64(b.pointsB), toF64(b.lower), toF64(b.upper), toF64(b.weights), + ); + } +} + +// Build the Problem from the buffers of a message. +function buildProblem(api, msg, withRegularisation) { + const g = msg.grid; + const origin = toF64(g.origin); + const counts = g.counts instanceof Uint32Array ? g.counts : Uint32Array.from(g.counts); + const step = toF64(g.step); + const problem = new api.Problem(origin, counts, step); + try { + if (withRegularisation && msg.regularisation) { + problem.setRegularisation(msg.regularisation); + } + addConstraints(problem, msg.constraints); + } catch (err) { + problem.free(); + throw err; + } + return problem; +} + +// Copy the Estimate object of wasm-bindgen to a plain object. +function plainEstimate(est) { + const out = { + total: Number(est.total), + vectors: Number(est.vectors), + constraints: Number(est.constraints), + working: Number(est.working), + }; + if (typeof est.free === 'function') est.free(); + return out; +} + +function delay0() { + return new Promise((r) => setTimeout(r, 0)); +} + +// Handle an estimate message. +function handleEstimate(api, msg) { + let problem = null; + try { + problem = buildProblem(api, msg, true); + const estimate = plainEstimate(problem.estimate(msg.settings)); + self.postMessage({ type: 'result', id: msg.id, estimate }); + } catch (err) { + postError(msg.id, err); + } finally { + if (problem && !needsReinit) { + try { + problem.free(); + } catch (_e) { + // Ignore. The problem is already freed or the module is not valid. + } + } + } +} + +// Handle a solve message. The function runs until the solve ends. +async function handleSolve(api, msg) { + const id = msg.id; + const settings = msg.settings || {}; + solving = true; + cancelFlag = false; + currentId = id; + let session = null; + let problem = null; + try { + problem = buildProblem(api, msg, true); + // Estimate first. start() consumes the problem, but the OverBudget + // error needs the estimate as an object. + const estimate = plainEstimate(problem.estimate(settings)); + try { + session = problem.start(settings); + } catch (err) { + const info = describeError(err); + if (info.fields.kind === 'OverBudget') { + const extra = { estimate }; + if (typeof info.fields.budget !== 'number') { + extra.budget = Number(settings.budgetBytes); + } + if (typeof info.fields.maxNodesPerAxis !== 'number') { + extra.maxNodesPerAxis = 1; + } + postError(id, err, extra); + return; + } + throw err; + } finally { + // start() consumed the problem. Do not use it again. + problem = null; + } + + let maxIters = START_ITERS; + let lastProgress = 0; + let last = null; + let first = true; + for (;;) { + if (cancelFlag) { + session.free(); + session = null; + self.postMessage({ type: 'cancelled', id }); + return; + } + const t0 = performance.now(); + last = session.step(maxIters); + const t1 = performance.now(); + + // Scale maxIters so that one step takes about TARGET_STEP_MS. + const dt = Math.max(t1 - t0, 1); + const scale = Math.min(Math.max(TARGET_STEP_MS / dt, 0.25), 4); + maxIters = Math.min(MAX_ITERS, Math.max(MIN_ITERS, Math.round(maxIters * scale))); + + if (last.done) break; + + // Always post after the first step. Later: not more often than + // every PROGRESS_MIN_MS. + if (first || t1 - lastProgress >= PROGRESS_MIN_MS) { + self.postMessage({ + type: 'progress', + id, + stage: last.stage, + iteration: last.iteration, + residual: last.residual, + maxViolation: last.violation, + }); + lastProgress = t1; + first = false; + } + // Let the worker handle a cancel message. + await delay0(); + } + + const field = session.field(); + const memory = api.memoryBytes(); + const warnings = Array.from(last.warnings || []); + const out = { + type: 'result', + id, + field, + memoryBytes: memory, + done: true, + converged: last.converged, + peakBytes: last.peakBytes, + iterations: last.iteration, + residual: last.residual, + maxViolation: last.violation, + warnings, + }; + session.free(); + session = null; + self.postMessage(out, [field.buffer]); + } catch (err) { + postError(id, err); + } finally { + if (session && !needsReinit) { + try { + session.free(); + } catch (_e) { + // Ignore. The session is already freed. + } + } + if (problem && !needsReinit) { + try { + problem.free(); + } catch (_e) { + // Ignore. + } + } + solving = false; + cancelFlag = false; + currentId = undefined; + } +} + +// Handle one message when the module is loaded. +function dispatch(api, msg) { + switch (msg.type) { + case 'init': + // The module is loaded. There is no reply. + return; + case 'cancel': + if (solving && (msg.id === undefined || msg.id === currentId)) { + cancelFlag = true; + } else if (!solving) { + self.postMessage({ type: 'cancelled', id: msg.id }); + } + return; + case 'estimate': + if (solving) { + self.postMessage({ type: 'error', id: msg.id, kind: 'SolveRunning' }); + return; + } + handleEstimate(api, msg); + return; + case 'solve': + if (solving) { + self.postMessage({ type: 'error', id: msg.id, kind: 'SolveRunning' }); + return; + } + // Set the flag now, so that the next message sees the running solve. + solving = true; + handleSolve(api, msg); + return; + default: + self.postMessage({ + type: 'error', + id: msg.id, + kind: 'InvalidInput', + message: `unknown message type: ${String(msg.type)}`, + }); + } +} + +// Take messages from the queue in order. The module loads first. +async function drain() { + if (draining) return; + draining = true; + try { + while (queue.length > 0) { + // A cancel or a refusal does not need a new module. All others do. + const msg = queue.shift(); + let api; + try { + api = await ensureLoaded(); + } catch (err) { + if (msg.type !== 'init') { + postError(msg.id, err, { kind: 'WorkerLoadError' }); + } else { + postError(undefined, err, { kind: 'WorkerLoadError' }); + } + continue; + } + try { + dispatch(api, msg); + } catch (err) { + postError(msg.id, err); + } + } + } finally { + draining = false; + } +} + +self.onmessage = (event) => { + const msg = event.data; + if (!msg || typeof msg.type !== 'string') return; + // Handle cancel at once when a solve runs. The module is already loaded. + if (msg.type === 'cancel' && solving) { + if (msg.id === undefined || msg.id === currentId) cancelFlag = true; + return; + } + queue.push(msg); + drain(); +}; + +// Load the module at start. A load error shows at the first message. +ensureLoaded().catch(() => {}); diff --git a/pyproject.toml b/pyproject.toml index ac4afd05..d186ddbb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -86,6 +86,7 @@ LoopStructural = [ [tool.uv.workspace] members = ["packages/*"] +exclude = ["packages/loop_interpolation_core"] [tool.uv.sources] loop-common = { workspace = true } @@ -93,6 +94,10 @@ loop-interpolation = { workspace = true } loopstructuralvisualisation = { workspace = true } map2loop = { workspace = true } LoopStructural = { workspace = true } +# The Rust core is not a workspace member (it builds with maturin), but the +# extra loop-interpolation[native] needs a source so that uv can lock it. +# uv reads its static metadata. It builds the core only when the extra is used. +loop-interpolation-core = { path = "packages/loop_interpolation_core" } [tool.pytest.ini_options] addopts = "--import-mode=importlib" diff --git a/specs/001-low-memory-interpolator/measurements.md b/specs/001-low-memory-interpolator/measurements.md new file mode 100644 index 00000000..f2d99070 --- /dev/null +++ b/specs/001-low-memory-interpolator/measurements.md @@ -0,0 +1,93 @@ +# Measurements + +## Baseline + +bytes_per_dof=84.64394 + +Note: R1 is a 100x100x100 grid, 2000 value and 500 normal constraints, default regularisation, `solve(5000, 1e-8)`. The value is peak heap bytes divided by dof. + +## After table cuts + +bytes_per_dof=79.071076 + +## After CG state machine + +bytes_per_dof=63.29426 + +Note: measured by the ignored test `session_r1` in `tests/memory.rs` (same R1 data, through `Problem::solve`). The test `baseline_r1` still measures the one-shot solver of the ported code, so it stays at the value under `After table cuts`. The value does not include the copy of the field that goes to JavaScript (8 bytes per dof). Same iteration count as the baseline (1054). + +## Inequality constraints (R2 and R3) + +Measured with the ignored tests `r2` and `r3` in `tests/scale_inequality.rs` (release build, counting allocator, 100^3 grid, 2000 value and 500 gradient rows, active pair bounds). + +| Problem | Pair bounds | Peak bytes | Ratio to R1 | Limit | Result | +|---|---|---|---|---|---| +| R1 (no bounds) | 0 | 63 195 988 | 1.000 | - | - | +| R2 | 200 | 63 258 388 | 1.001 | 1.2 (SC-002) | pass | +| R3 | 20 000 | 69 435 988 | 1.099 | 1.2 (SC-011) | pass | + +The first run had a ratio of 1.280 for R3 (fail). Two changes fixed it: ADMM no longer keeps a copy of the data right-hand side (it rebuilds it each outer iteration), and the constraint rows are reserved before they are added (no `Vec` doubling). All runs converge in 1062 iterations. + +## Reference problems + +Measured on the development machine (Windows, release build, one run). Tests: `tests/reference_problems.rs`. Run the ignored tests with `cargo test --release --test reference_problems -- --ignored --nocapture --test-threads=1`. + +| Item | Result | Limit | Result | +|---|---|---|---| +| SC-001: R1 (100^3, 2000 value and 500 gradient rows) peak | 63 195 988 bytes | 100 MB | pass | +| SC-007: R1 solve time (desktop, report only) | 53.5 s and 57.2 s | 60 s (Chromium, reference machine) | report only | +| SC-008: 250^3, single precision, 2 GB budget, peak | 456 131 092 bytes | 1.5 GB | pass | +| SC-008: time of that solve | 2907 s | none | report only | +| SC-012: peak against dof at 50^3, 100^3, 150^3 (R^2) | 0.99986 | 0.99 | pass | +| SC-009: estimate against peak, 10^3 to 200^3 (15 sizes) | 1.049 to 1.122 | 1.0 to 1.25 | pass | + +SC-009 table (500 value and 125 gradient rows, 20 iterations): + +| n | estimate | peak | ratio | +|---|---|---|---| +| 10 | 286 077 | 255 060 | 1.122 | +| 20 | 957 057 | 892 340 | 1.073 | +| 40 | 5 307 297 | 5 057 972 | 1.049 | +| 60 | 15 915 777 | 15 032 756 | 1.059 | +| 80 | 35 479 137 | 33 525 684 | 1.058 | +| 100 | 66 694 017 | 62 770 612 | 1.063 | +| 120 | 112 257 057 | 105 263 540 | 1.066 | +| 150 | 213 403 317 | 200 613 652 | 1.064 | +| 200 | 491 923 617 | 459 732 404 | 1.070 | + +Notes: + +- The memory model has three parts: about 53 bytes for each node, about 1020 bytes for each n^2 (shell and border scratch), and about 190 bytes for each stored constraint row. The first estimate had a low surface term and failed SC-009 from n = 120. +- The R1 time of 53 to 57 s is close to the 60 s limit of SC-007. The limit is for Chromium on the reference machine and is checked by T052. +- The field of R1 for the browser comparison is written to `packages/loop_interpolation_core/target/r1_field.f64` (8 000 000 bytes, 1062 iterations, converged). + +## Comparison with the Python matrix solver + +Measured on the development machine (Windows, 20 logical cores, release build, one run each, about 15 to 20 percent noise). Both solvers solve the same synthetic problem at tolerance 1e-8: R1-type data (value and gradient rows, scaled with the grid; 2000 and 500 rows at 100^3). The peak memory is the extra peak working set of the process above the interpreter. Each run is a separate process. + +| Grid | Matrix solver (`cg`) | Native, 1 thread | Native, `parallel` | +|---|---|---|---| +| 30^3 | 2.7 s, 68 MB | 3.6 s, 3 MB | not run | +| 50^3 | 9.7 s, 305 MB | 12.3 s, 13 MB | 5.0 s, 15 MB | +| 70^3 | 26.1 s, 836 MB | 28.3 s, 31 MB | not run | +| 100^3 | 71.3 s, 2445 MB | 78.4 s, 84 MB | 20.8 s, 86 MB | + +- At 100^3 the native solver uses 29 times less memory (84 MB against 2445 MB). +- On one thread the native solver is 10 to 30 percent slower. With `parallel` it is 3.4 times faster at 100^3. +- The golden tests (`tests/golden`, FD engine, 7 cases) agree with the native solver to 1e-9 (limits 1e-6 for values and 1e-5 for gradients). +- The browser (WebAssembly, one thread) solves the 60^3 test problem of `tests_web` in about 12 s. The size of the module is 268 693 bytes, 92 539 bytes after gzip (SC-010). + +## Browser reference run (R1 in Chromium) + +Test: `tests_web/reference.spec.js` (runs only with `RUN_REFERENCE=1`). Chromium, one thread, no cross-origin isolation, development machine (Windows, 20 logical cores). The browser field is compared with `target/r1_field.f64` from the Rust test `r1_write_field`. + +| Item | Result | Limit | Result | +|---|---|---|---| +| Memory (`WebAssembly.Memory.buffer.byteLength`) | 65 798 144 bytes | 100 MB (SC-001) | pass | +| Difference from the Rust field | 0 (bit-identical, 1062 iterations, converged) | 1e-6 of the range (SC-003) | pass | +| Progress messages (worst gap) | 741 (573 ms) and 649 (913 ms) in two runs | one in each second (SC-006) | pass | +| Solve time | 93.3 s and 107.2 s (two runs) | 60 s on the reference machine (SC-007) | report only | + +- The time is above 60 s on this machine. SC-007 is checked on the reference machine (a laptop with 4 cores and 16 GB, OP3). The native Rust solve takes 53 to 57 s on one thread, so WebAssembly is about 1.7 to 2 times slower than native on one thread. +- The worst gap between progress messages was 913 ms in one run. The limit is 1000 ms. The Worker posts progress at most every 100 ms, so this gap comes from one long step. +- R2 (pair bounds) is not in the browser run. The Rust tests cover R2 and R3. diff --git a/specs/001-low-memory-interpolator/plan.md b/specs/001-low-memory-interpolator/plan.md new file mode 100644 index 00000000..ac87ea53 --- /dev/null +++ b/specs/001-low-memory-interpolator/plan.md @@ -0,0 +1,592 @@ +# Implementation plan: Low-memory interpolator for the browser + +**Feature**: `001-low-memory-interpolator` | **Date**: 2026-10-27 | **Spec**: [spec.md](spec.md) + + + +## Open points + +| Id | Question | Default if the user does not answer | Tasks that wait | +|---|---|---|---| +| OP1 | Does the memory per dof of the core stay under the target of D4 (about 75 bytes per dof in `f64`)? Tasks T009, T010 and T020 measure it. | Over 95 bytes per dof after T020: stop and change the design. Between 75 and 95: ask the user. | T039 to T045, T052 (all browser tasks) | +| OP2 | Does the `.wasm` file stay under 2 MB after gzip (SC-010)? Task T044 measures it. | Review the dependencies and the size profile. | T045 | +| OP3 | The reference machine of SC-007 and the Firefox and Safari smoke tests need a person. | CI reports the time only. The release check is manual. | none (manual) | + +The orchestrator starts a task with `Blocked by` only after the user +confirms the open point. + +The active-set polish of `loop-rs` is **not** in this plan (see D5). The spec +does not need it (principle: no feature that the spec does not need). + +## Summary + +Make a new Rust core, `packages/loop_interpolation_core`. Start from the matrix-free +finite-difference code in `loop-rs` (`crates/interpolation-fd`). Change it so +that it (1) uses a known, small amount of memory, (2) builds for +`wasm32` and runs in a Web Worker, and (3) runs as a state machine that a +caller can step, watch and cancel. Ship the core in three forms from one +source: a Rust crate, a Python wheel, and a WebAssembly package. + +`loop_interpolation` gets a new solver choice, `"native"`. It is a drop-in for +the existing `FiniteDifferenceInterpolator`. With this choice, the Python +class does not assemble sparse rows. It passes its stored constraints to the +core. The Python package stays pure Python. The core is an optional extra. + +A web page calls the WebAssembly build directly. The page does all coordinate +transformation (FR-020). The core works in grid coordinates only. + +## Technical context + +- **Packages changed** + - New: `packages/loop_interpolation_core` (Rust, PyO3, wasm-bindgen). + - Changed: `packages/loop_interpolation` (new solver choice, new extra). + - Changed: release and CI files (see "Project structure"). +- **Source of the code**: copy `loop-rs/crates/interpolation-fd` at its current + head on branch `lachlan/drillhole` (commit + `bff51342da8ebff7ca3c87e07c1d4fbd31279c8b`, checked on 2026-10-27). Copy + `src/`, `tests/edge_cases.rs` and `tests_python/`. Write the commit hash in + the package README. The crate uses Rust edition 2024, `pyo3 0.29` and + `numpy 0.29`. Do not use a git dependency. Do not use the `LoopAI` copy. It is + older and it has no active-set solver. Take only the idea of a thin adapter + from it. +- **New dependencies** (all pure Rust) + - `wasm-bindgen` (browser glue, feature `wasm` only). + - `pyo3` and `numpy` (feature `python` only, as in `loop-rs`). + - `rayon` (feature `parallel` only, now off by default). + - No `serde`, no `serde_json`, no `regex`. They add size (SC-010). +- **Python side**: no new required dependency. New optional extra + `native = ["loop-interpolation-core"]`. +- **Build tools**: `maturin` (wheel), `wasm-pack` or `wasm-bindgen-cli` + with `wasm-opt` (browser package), `cargo`. +- **Performance targets**: SC-001 to SC-010 of the spec. "Test strategy" + says how to measure each one. +- **Platforms**: wheel for Windows, macOS, Linux. Browser package tested in + Chromium on Linux (CI) first. + +## Repo-fit check + +This repository has no constitution. These are the rules that come from the +repository's own decisions. Each row says how the plan agrees. + +| Rule | Result | How the plan agrees | +|---|---|---| +| A piece that is useful alone is a separate package (repo structure decision) | Pass | The core is useful with no Python (browser). It is its own package. | +| `loop_interpolation` stays pure Python and its behaviour does not change (FR-019) | Pass | The core is an optional extra. Import is lazy. A missing core gives an `ImportError` with an install hint when the user asks for `"native"`. | +| `release-please` paths are scoped (fix of 2026-09-27) | Pass | The new package gets its own path. The root `exclude-paths` list gets the new path. Task T001. | +| `uv sync` must work with no Rust compiler | Pass, with a change | The root `pyproject.toml` has `[tool.uv.workspace] members = ["packages/*"]`. This would make the core a member. Task T002 adds `exclude = ["packages/loop_interpolation_core"]`. | +| Keep CI small | Pass | One new workflow with path filters. The browser job runs only for this package. The big reference problem R1 runs only on manual dispatch. | +| Test first | Pass | Each phase starts with tests that fail. Task order in `tasks.md` follows this. | +| STE documentation | Pass | The package README, the user guide page and these files use STE. | +| No duplicate numerical code | Pass | After the port, one core serves Python, the browser and (later) `loop-rs`. `loop-rs` can depend on the new crate. That change is out of scope. | + +## Design + +### D1. What the core takes out of the `loop-rs` code + +| Item | Decision | Reason | +|---|---|---| +| Grid rotation (`rotation`, `to_local`, `direction_to_local`) | **Remove** | FR-020: the caller transforms. The Python rotation is not applied today, so there is no Python behaviour to match. | +| `PyO3` bindings | Keep, behind feature `python`. Rewrite for the new API. | Needed for story 6. | +| `rayon` | Move behind feature `parallel` | Browser build runs on one thread (FR-006). | +| Dense Cholesky of the working-set Gram matrix | **Replace** (D5) | Memory grows with the square of the active rows (FR-002). | +| `main.rs` demo binary | Remove | Not needed. | +| `serde` optional feature | Remove | Not needed. SC-010. | +| Build output in the `loop-rs` crate folder (`loop_interpolation_core/*.pyd`, `__pycache__`) | Do not copy | Build output. | +| Closure-style solvers (`solve`, `solve_admm`, ...) | **Replace** with session objects (D3) | Needed for progress and cancel in a single-thread browser. | +| Cross-derivative weights | Keep the `loop-rs` meaning (used as given). The Python adapter divides `dxy`, `dxz`, `dyz` by 4 as `StructuredGrid.get_operators` does. | `loop-rs` research found a factor-4 difference. The adapter hides it. | + +### D2. Types and data (Rust) + +```text +Grid origin [f64;3], counts [usize;3], step [f64;3] (no rotation) +Problem Grid + constraint records + RegularisationWeights + BorderWeights + + optional directional regularisation +Constraint fixed-size record. The `loop-rs` records already hold the 8 + (or 16) corner indices and the trilinear coefficients + (`PointConstraint`: 8 x usize, 8 x f64, value, weight; + `PairConstraint`: 16 + 16 + weight; the inequality records add + lower and upper). Keep this layout. It is linear in the number + of constraints and has no matrix. The memory estimate counts + these bytes (up to 272 bytes for a pair record). +Precision enum { F64, F32 } +Settings precision, tol, max_iter, budget_bytes, ADMM settings +MemoryEstimate { vectors, constraints, working, total } +SolveSession state machine, see D3 +SolveReport converged, iterations, residual, max_violation, peak_bytes, + warnings +CoreError enum, one case for each error kind of FR-012, FR-015, FR-018 +Field the solved values and the grid; evaluate, evaluate_gradient +``` + +Exact fields and signatures are in D8. + +The vector element type is generic over a small private trait `Scalar` (`f32` +and `f64`). The trait is in the crate. It adds no dependency. Dot products and +norms always add in `f64` (FR-013). + +### D3. Solve sessions (progress, cancel, no thread) + +A single-thread browser module cannot receive a "cancel" message while a long +loop runs. So the solver must give control back often. The core does this +with a session object, not with a clock or a thread. + +```text +let mut s = problem.start(settings)?; // allocates, checks the budget +loop { + match s.step(max_iters) { // runs at most max_iters iterations + Step::Running(progress) => { /* report; the caller may drop `s` to cancel */ } + Step::Done(report) => break, + } +} +let field = s.finish()?; // moves the field out, no copy +``` + +- `progress` has the stage, the iteration, the residual and the largest + violation (FR-009). +- To cancel, the caller drops the session. Drop frees all memory (FR-010). + The time to cancel is the time of one `step`. The web wrapper picks + `max_iters` so that one step takes about 100 ms. +- `s.best_so_far()` returns the current field marked "not converged" (story 3, + scenario 4). +- The native one-shot call `problem.solve(settings)` is a loop over `step`. + Python uses it. One code path serves all callers. +- Each solver (CG, ADMM, active set, hybrid) is a state machine with its + state in the session. This replaces the closures of `loop-rs`. + +### D4. Memory design (FR-001, FR-002, SC-001, SC-002) + +Count of full-size items, `n` = dof. Source: reading `loop-rs` +`interpolator.rs` and `cg.rs`. Nobody has measured these. Task T009 measures. + +| Item (in `loop-rs`) | Bytes per dof, `f64`, desktop | Plan | +|---|---|---| +| CG: `x`, `r`, `z`, `p`, `ap` | 40 | Cut `z` (`r.z` = `sum(r^2 * inv_diag)`). Keep 4. | +| `rhs` | 8 | Build straight into `r`, then drop. Cut. | +| Jacobi diagonal `diag` | 8 | Keep as `inv_diag`. | +| `scratch`: one `dof`-length border buffer | 8 | Share with `ap` if the border pass allows (T020). Else keep. | +| `scratch`: 6 slots for each shell row (about 17% of nodes at 100^3) | about 8 | Keep. | +| `shell_row_lookup: Vec` (filled with `usize::MAX`, size `dof`) | 8 (4 on `wasm32`) | Store as `u32`, or compute the position from `(i,j,k)`. Task T010. | +| `shell_row_indices`, `shell_output_indices` | about 3 | Keep. Store as `u32`. | +| Directional weights `[Vec; 6]` | 48, only if used | Lazy. R1 does not use them. The estimate adds 48 if the problem uses them. | +| ADMM: base `rhs` copy and `rhs_admm` | 16 | Keep one (D5.1). | +| Result `x` | 0 | Moved out. | + +Target after the cuts, plain CG: `x`, `r`, `p`, `ap`, `inv_diag` (40) + +border scratch (8, or 0 if shared) + shell scratch (8) + lookup (4) + +indices (3) = about 63 bytes per dof. ADMM adds one vector (8), so about +71. SC-001 allows 100 bytes per dof. The rest is for the constraint records +(0.4 MB for R1, 5.4 MB for R3), the module, and the copy of the field that +goes to JavaScript (8 bytes per dof). The margin is small. So task T009 +measures the port **before** any cut, and again after the cuts of T010 and T020. If +the value is over 95 bytes per dof after the cuts, stop (OP1). + +Large allocations use `Vec::try_reserve_exact`. A failure gives +`CoreError::OutOfMemory` and not an abort (FR-012, story 4, scenario 3). + +`Problem::estimate_memory(settings)` is arithmetic on counts. It allocates +nothing (FR-011). The estimate adds a safety margin. SC-009 checks it. + +### D5. Inequality constraints + +1. **ADMM only.** Port the `loop-rs` ADMM into a session (task T022). Its + operator `N + rho Q^T Q` is already matrix-free. It meets SC-004 at 1e-4. + The state holds `z` and `u` (one entry per inequality row) and one + full-size vector. Memory is linear in the dof and the rows (FR-002). +2. **Not in this plan: active-set polish and hybrid.** The `loop-rs` active + set factors a dense Gram matrix of the active rows. Its size is the square + of the active rows. This breaks FR-002. A matrix-free replacement would + serve only the 1e-8 tolerance, and the spec puts that tolerance out of + scope. So the core has no `ActiveSet` and no `Hybrid` method. A later spec + can add them. Known risks for that later work, from the `loop-rs` notes: an + inexact projection leaks residual into the pinned nodes; the release check + must not run on an unconverged residual. +3. **Infeasible problems** (FR-015). ADMM reports a primal residual that does + not fall. The session stops after a stall test (20 outer iterations + without a fall of 1 percent) and returns `CoreError::Infeasible` with the + 20 rows that have the largest violation. + +### D6. Browser package + +- Feature `wasm`. Crate type `cdylib`. Build target `wasm32-unknown-unknown`. + No threads, no `SharedArrayBuffer`. +- JavaScript API (classes made by `wasm-bindgen`): + + ```text + new Problem(origin: Float64Array(3), counts: Uint32Array(3), step: Float64Array(3)) + problem.setRegularisation({ dxx, dyy, dzz, dxy, dxz, dyz, dx, dy, dz }) + problem.addValue(points: Float64Array(3n), values: Float64Array(n), weights: Float64Array(n)) + problem.addGradient / addNormal / addTangent / addInterface(...) + problem.addValueBound(points, lower, upper, weights) + problem.addPairBound(pointsA, pointsB, lower, upper, weights) + problem.estimate(settings) -> { total, vectors, ... } + problem.start(settings) -> Session // consumes the problem; throws on budget error + session.step(maxIters) -> { done, stage, iteration, residual, violation } + session.field() -> Float32Array | Float64Array // one copy out + session.evaluate(points: Float64Array(3m)) -> Float64Array + session.evaluateGradient(points) -> Float64Array(3m) + session.free() // cancel + ``` + +- A small JavaScript worker wrapper (`worker.js`) owns the `Problem`. It + receives messages, calls `step` in a loop with `setTimeout(0)` between + steps so that a "cancel" message is handled, and posts progress. The main + thread never loads the module (story 3, FR-008). +- Large arrays go to the worker as transferable buffers (no copy). +- Size (SC-010): `opt-level = "z"` for the browser profile only, `lto`, + `panic = "abort"`, `wasm-opt -Oz`. Measure after phase C. If over 2 MB, + review the dependency list. +- Peak memory in the browser is read from `WebAssembly.Memory.buffer.byteLength` + after the solve. WebAssembly memory never shrinks, so this is the peak. + +### D7. Python drop-in (`loop_interpolation`) + +Changes in `packages/loop_interpolation`: + +1. `_solver_strategy.resolve_solver_choice` accepts `"native"`. Today it keeps + the list `["cg", "lsmr", "admm", None]` and logs a warning for any other + string. Add `"native"` to the list. +2. The solver can be set at three times: `InterpolatorFactory.create_interpolator(solver=...)` + (before setup), `InterpolatorBuilder.use_solver` (before setup if the user + calls it first), and `solve_system(solver=...)` or + `InterpolatorBuilder.solve(solver=...)` (after setup). So + `FiniteDifferenceInterpolator.setup_interpolator` checks + `self.solver == "native"` **at setup time**. If true, it stores the + weights and keywords and does **not** call the `add_*` methods that + assemble sparse rows. This saves the Python memory. If the solver is set + later, the rows are already built. The result is still correct, because + the adapter (item 4) reads the raw data with `get_value_constraints()`, + `get_gradient_constraints()`, `get_norm_constraints()`, + `get_tangent_constraints()`, `get_interface_constraints()`, + `get_inequality_value_constraints()` and + `get_inequality_pairs_constraints()`, not the rows. In that case + `solve_system` logs one warning that says the memory saving needs + `solver="native"` before setup. Task T037 adds the setup branch. Task T030 + adds tests for both orders. `update()` calls + `setup_interpolator(**self._setup_kwargs)` again, so the branch must be + safe to run twice. The setup branch must still return the diagnostics + report (`finalize_setup_diagnostics_report`). T037 checks that the report + does not need the rows. +3. `DiscreteInterpolator.solve_system` gets one branch for `"native"`. It + calls a new function `_native.solve(interpolator, tol, solver_kwargs)` and + sets `self.c` and `self.up_to_date`. +4. New module `_native.py` (the adapter, about 150 lines). It maps data to core + calls: + + | Python data / method | Core call | + |---|---| + | value (`cpw`) | `add_value` | + | gradient (`gpw`), direction only | `add_gradient` | + | normal (`npw`), with length | `add_normal` | + | tangent (`tpw`) | `add_tangent` | + | interface (`ipw`), star pairing | `add_interface` | + | value inequality | `add_value_bound` | + | inequality pairs | `add_pair_bound` | + | `dxx` ... `dzz`, `dxy` ... (with the factor 4) | regularisation weights | + | `dx`, `dy`, `dz` | border weights | + | directional regularisation | `add_directional_regularisation` | + + Names and the meaning of the gradient and normal keys are not the same in + the two projects (see the `LoopAI` README). The parity tests (below) fix + the mapping. Do not trust this table before the tests pass. +5. Settings that the core does not support raise `NotImplementedError` with + the name of the setting (FR-026): a region mask, column scaling, + `use_regularisation_weight_scale`, constant-norm iterations, + `interface_pair_mode` other than star, 2D supports, rotated supports. +6. `solver_kwargs` for `"native"`: `tol`, `maxiter`, `precision` + (`"f64"` or `"f32"`), `budget_bytes`, and the ADMM + settings (`rho`, `inner_max_iter`, `inner_tol`, `admm_abs_tol`, `admm_rel_tol`). + An unknown key raises `NotImplementedError` that names the key. +7. The node order of the core must equal the node order of `StructuredGrid`. + A test fixes this. +8. `latest_solve_timing` gets the core's timing. The interpolator keeps the + `SolveReport` as `latest_native_report`. + +### D8. Exact Rust and Python API, and the fixture format + +A sub agent must not need to make a design decision. These items are fixed. + +**Rust, crate `loop_interpolation_core`.** Names are final. A field that this +list does not show does not exist. + +```text +// error.rs (Debug, Clone, PartialEq; Display and std::error::Error) +pub enum BoundKind { Value, Pair } +pub struct ViolatedConstraint { pub index: usize, pub kind: BoundKind, pub violation: f64 } +pub enum CoreError { + InvalidInput { constraint: String, reason: String }, // constraint = "value[12]" + GridTooSmall { axis: usize, nodes: usize }, // fewer than 3 nodes + GridTooLarge { nodes: u64, max_nodes: u64 }, // MAX_NODES = isize::MAX as u64 / 8 + NoConstraints, + OverBudget { estimate_bytes: u64, budget_bytes: u64, max_nodes_per_axis: usize }, + OutOfMemory { requested_bytes: u64 }, + Infeasible { worst: Vec }, // at most 20, falling violation + PointOutsideGrid { index: usize }, + Unsupported { setting: String }, + SolveRunning, // used by the wasm layer +} + +// settings.rs +pub enum Precision { F64, F32 } +pub struct Settings { + pub precision: Precision, // default F64 + pub tol: f64, // relative residual, default 1e-8 + pub max_iter: usize, // default 5000 (total inner CG iterations) + pub budget_bytes: Option, // default None + pub rho: f64, // ADMM; defaults copied from loop-rs python.rs + pub inner_max_iter: usize, + pub inner_tol: f64, + pub admm_abs_tol: f64, + pub admm_rel_tol: f64, +} + +// report.rs (Debug, Clone) +pub enum Stage { Setup, Solve, CheckConstraints } +pub struct Progress { pub stage: Stage, pub iteration: usize, pub residual: f64, pub max_violation: f64 } +pub struct SolveReport { + pub converged: bool, pub iterations: usize, pub residual: f64, + pub max_violation: f64, pub peak_bytes: u64, pub warnings: Vec, +} +pub enum Step { Running(Progress), Done(SolveReport) } +pub struct MemoryEstimate { pub vectors: u64, pub constraints: u64, pub working: u64, pub total: u64 } + +// field.rs +pub enum FieldData { F64(Vec), F32(Vec) } +pub struct Field { pub grid: StructuredGrid3D, pub data: FieldData } +impl Field { + pub fn values_f64(&self) -> Vec; + pub fn evaluate(&self, points: &[f64]) -> Result, CoreError>; // 3m in, m out + pub fn evaluate_gradient(&self, points: &[f64]) -> Result, CoreError>; // 3m in, 3m out +} + +// memory.rs +pub struct MemTracker { /* current, peak, budget */ } +impl MemTracker { + pub fn new(budget: Option) -> Self; + pub fn try_zeroed(&mut self, n: usize) -> Result, CoreError>; + pub fn release(&mut self, bytes: u64); + pub fn current(&self) -> u64; pub fn peak(&self) -> u64; +} +pub struct ProblemCounts { /* dof, family counts, uses_directional */ } +pub fn estimate(c: &ProblemCounts, s: &Settings) -> MemoryEstimate; +pub fn max_grid_for_budget(c: &ProblemCounts, s: &Settings, budget: u64) -> usize; + +// problem.rs (arrays are flat: points = 3n values; vectors = 3n values) +pub struct Problem { /* FiniteDifferenceInterpolator, counts, warnings */ } +impl Problem { + pub fn new(origin: [f64; 3], counts: [usize; 3], step: [f64; 3]) -> Result; + pub fn set_regularisation(&mut self, w: RegularisationWeights); + pub fn set_border_regularisation(&mut self, w: BorderRegularisationWeights); + pub fn add_value(&mut self, points: &[f64], values: &[f64], weights: &[f64]) -> Result<(), CoreError>; + pub fn add_gradient(&mut self, points: &[f64], vectors: &[f64], weights: &[f64]) -> Result<(), CoreError>; // direction only + pub fn add_normal(&mut self, points: &[f64], vectors: &[f64], weights: &[f64]) -> Result<(), CoreError>; // with length + pub fn add_tangent(&mut self, points: &[f64], vectors: &[f64], weights: &[f64]) -> Result<(), CoreError>; + pub fn add_interface(&mut self, points: &[f64], ids: &[f64], weight: f64) -> Result<(), CoreError>; + pub fn add_directional_regularisation(&mut self, directions: &[f64], weight: f64) -> Result<(), CoreError>; + pub fn add_value_bound(&mut self, points: &[f64], lower: &[f64], upper: &[f64], weights: &[f64]) -> Result<(), CoreError>; + pub fn add_pair_bound(&mut self, points_a: &[f64], points_b: &[f64], lower: &[f64], upper: &[f64], weights: &[f64]) -> Result<(), CoreError>; + pub fn warnings(&self) -> &[String]; + pub fn estimate_memory(&self, s: &Settings) -> Result; + pub fn residual_f64(&self, field: &Field) -> f64; // phase E + pub fn start(self, s: Settings) -> Result; // consumes + pub fn solve(self, s: Settings) -> Result<(Field, SolveReport), CoreError>; // loop over step +} + +// session.rs (the session owns the Problem, so it has no lifetime) +impl SolveSession { + pub fn step(&mut self, max_iters: usize) -> Result; + pub fn best_so_far(&self) -> Result<(Field, SolveReport), CoreError>; // converged = false until Done + pub fn finish(self) -> Result<(Field, SolveReport), CoreError>; // moves the field out +} +``` + +Notes: a bound of `-inf` or `+inf` means "no bound" and is valid. `SolveReport` +has no time field because `wasm32-unknown-unknown` has no clock; the caller +measures time. A solve that ends by `max_iter` returns `Ok` with +`converged = false`. The ported rows that drop a constraint with weight 0 or a +point outside the grid are wrapped: the wrapper checks first and gives an error +(weight) or a counted warning (outside). + +**Python module `loop_interpolation_core`** (PyO3, abi3 for Python 3.9). + +```text +class Problem(origin: ndarray(3), counts: ndarray(3), step: ndarray(3)) + set_regularisation(dict) # keys dxx dyy dzz dxy dxz dyz (core meaning, see fixture) + set_border_regularisation(dict) # keys dx dy dz + add_value / add_gradient / add_normal / add_tangent(points, vectors_or_values, weights) + add_interface(points, ids, weight) + add_directional_regularisation(directions, weight) + add_value_bound(points, lower, upper, weights) + add_pair_bound(points_a, points_b, lower, upper, weights) + estimate(settings: dict) -> dict # vectors constraints working total + solve(settings: dict) -> (ndarray, dict) # field, report +``` + +Settings keys: `precision` (`"f64"` or `"f32"`), `tol`, `max_iter`, +`budget_bytes`, `rho`, `inner_max_iter`, `inner_tol`, `admm_abs_tol`, +`admm_rel_tol`. The report keys are the `SolveReport` field names. + +**Fixture file `fixtures/fixture_20.bin`** (little endian, no header other +than the magic). Bytes 0 to 3 are the ASCII text `LIF1`. Then a list of +blocks. Each block is a `u32` length `N` and `N` values of `f64`. The order is +fixed: + +```text +origin(3) counts(3) step(3) reg(6: dxx dyy dzz dxy dxz dyz) border(3: dx dy dz) ridge(1) +value_points value_values value_weights +gradient_points gradient_vectors gradient_weights +normal_points normal_vectors normal_weights +tangent_points tangent_vectors tangent_weights +interface_points interface_ids interface_weight(1) +expected_field(N = counts product) +``` + +All weights are in core meaning (the cross-derivative weights are already +divided by 4). The generator `fixtures/make_fixture.py` writes the file and +documents the mapping from the Python data columns. The expected field is the +reference solution of the spec: the matrix solver with `solver="cg"` and +`tol=1e-10`. + +## Test strategy + +Order in each phase: write the tests, see them fail, then implement. + +- **Unit tests (Rust, `cargo test`)**: all tests of the `loop-rs` crate move + with the code, so the port starts from a green suite. New tests: session + `step` gives the same answer as one-shot; dropping a session frees the + memory; `estimate_memory` for known sizes; `try_reserve` failure path (a + budget of 1 byte); `f32` against `f64`; no-op when no inequality rows. +- **Memory tests (Rust)**: a counting global allocator in a test binary + measures the peak. Check `peak <= estimate <= 1.25 * peak` for sizes + 10^3 to 100^3 in CI. Sizes up to 200^3 run on manual dispatch (SC-009). The + counting test also checks the number of full-size vectors (D4). +- **Parity tests (Python)**: in `packages/loop_interpolation/tests/ + test_native_*.py`. Skip with `pytest.importorskip` when the core is not + installed. Start from `loop-rs/crates/interpolation-fd/tests_python/`. + For each constraint family, build the same problem with the matrix solver + and with `"native"` on a 30^3 grid. Compare the fields (SC-003). Include the + inequality problems against the Python ADMM (SC-004), the node-order test, + the unsupported-setting errors, and a test that the native path builds no + sparse rows. +- **Cross-build fixtures**: one small problem (20^3) is stored as `fixture_20.bin` (D8) + with its expected field. The Rust test, the Python test and the browser test + read the same file. This proves the three builds agree (story 1, scenario + 4). +- **Browser tests (Playwright, Chromium)**: load the package in a Worker, run + the 20^3 fixture, check the field. A second test runs a 60^3 problem and + checks (a) the main-thread timer delay is below 100 ms (SC-006), (b) cancel + stops in under 1 s (FR-010), (c) a budget error appears for a budget that + is too small (story 4). Memory is `WebAssembly.Memory.buffer.byteLength`. +- **Reference problems R1 and R2 (manual dispatch)**: run in Chromium and on + the desktop. They record peak memory, time, and field differences in the + job summary (SC-001, 002, 003, 004, 007). The result goes in + `specs/001-low-memory-interpolator/measurements.md`. +- **Size check**: CI prints the compressed size of the `.wasm` file and fails + above 2 MB (SC-010). + +## Phases + +Each phase ends with passing tests and can merge alone. + +| Phase | Content | Stories and requirements | Exit check | Tasks | +|---|---|---|---|---| +| A | Port, remove rotation, features, measure memory | FR-001, 002, 003, 005, 018, 020, 023 | Ported tests green. Memory per dof known. | T006 to T011 | +| B | Flat input, memory estimate, sessions, CG and ADMM state machines, family checks | Story 1 (core), 2, 3 (core), 4; FR-004, 009 to 012, 014, 015, 016, 024 | Memory tests. SC-004 at 1e-4. | T012 to T026 | +| D | `python` bindings, adapter, `"native"` choice, parity tests | Story 6; FR-017, 019, 021, 025, 026 | Parity tests pass. Existing tests unchanged. | T027 to T038 | +| C | `wasm` build, worker wrapper, browser tests | Story 1, 3; FR-006, 008; SC-006, SC-010 | Browser tests pass in CI. | T039 to T045 | +| E | `f32` mode | Story 5; FR-013, 022; SC-005, SC-008 | `f32` tests. | T046 to T050 | +| F | Measurement, CI, docs, final checks | SC-001, 002, 007, 008, 009, 011, 012 | `native.yml` green. `measurements.md` filled. | T051 to T056 | + +Setup tasks T001 to T005 come first. The plan of the earlier draft had a phase +for the active-set polish. It is removed (D5). + +Phase D can start after phase B. It does not need the browser build. + +## Project structure + +```text +packages/loop_interpolation_core/ + Cargo.toml features: parallel, python, wasm + pyproject.toml maturin; import name loop_interpolation_core + README.md STE; source commit of loop-rs; contract + src/ + lib.rs + par.rs rayon on or off (feature parallel) + grid.rs ported, no rotation + stencil.rs ported + interpolator.rs ported (not split), tables built lazily + error.rs settings.rs report.rs field.rs + memory.rs MemTracker, estimate, max_grid_for_budget + session.rs SolveSession + cg.rs CG as state machine + admm.rs ADMM as state machine + problem.rs flat input, validation, start, solve + scalar.rs f32 / f64 trait (phase E) + python.rs feature python + wasm.rs feature wasm + web/ + worker.js step loop, messages, cancel + index.d.ts + README.md + tests/ + common/mod.rs memory.rs memory_estimate.rs input_validation.rs + session_cg.rs session_admm.rs scale_inequality.rs constraint_families.rs + parity_fixture.rs precision_f32.rs reference_problems.rs edge_cases.rs + fixtures/ + make_fixture.py fixture_20.bin + scripts/ + build_wasm.sh check_wasm_size.py + tests_web/ Playwright tests +packages/loop_interpolation/ + pyproject.toml new extra: native + src/loop_interpolation/ + _native.py adapter (new) + _solver_strategy.py accepts "native" + _finite_difference_interpolator.py skip row assembly for "native" + _discrete_interpolator.py one branch in solve_system + tests/test_native_*.py parity tests (new) +.github/workflows/native.yml new; path filters; wheel + wasm + browser +.release-please-config.json new package path; root exclude-paths +.release-please-manifest.json new entry +pyproject.toml (root) [tool.uv.workspace] exclude the core +docs/ user guide page for solver="native" +specs/001-low-memory-interpolator/ spec, research, plan, tasks, measurements +``` + +## Risks + +| Risk | Effect | Mitigation | +|---|---|---| +| Real memory is above 100 bytes per dof | SC-001 fails | Phase A measures first. D4 lists the vectors to remove. | +| Node order or key meaning differs between Python and the core | Wrong field with no error | Parity tests for each family. Node-order test. | +| Python builds sparse rows before the solver is known | Memory saved only in the browser | T030 and T037 handle both call orders. A test shows that no rows are built when the solver is set first. | +| `wasm` size over 2 MB | SC-010 fails | No `serde`. Size profile. Check in CI. | +| ADMM needs too many iterations for SC-007 | Time target fails | Report the time (T051). The time target can change (assumption 2 of the spec). | +| Browser refuses memory growth | Tab abort | `try_reserve`, budget, estimate with margin. | +| Single thread is too slow for SC-007 | Time target fails | Measure. Fused kernel is already ported. `parallel` feature for desktop only. Targets can change (assumption 2). | +| Maintaining two Rust copies (`loop-rs` and here) | Drift | Record the source commit. Later, `loop-rs` depends on this crate (out of scope). | + +## Complexity tracking + +No rule is broken. Two items add complexity on purpose. + +| Item | Why it is necessary | Simpler option, and why it is not sufficient | +|---|---|---| +| Session state machines for all solvers | A single-thread browser module must give control back to receive "cancel" and to report progress. | A closure with a callback cannot cancel in a browser with no threads and no `SharedArrayBuffer` (FR-006). | +| New package outside the uv workspace | Contributors with no Rust compiler must still run `uv sync`. | A workspace member forces Rust on all contributors. | + +## Coverage of the spec + +`tasks.md` has the table that maps each FR and SC to its tasks. All FR and +SC items have at least one task. Two items need a person and have no +automatic task: the Firefox and Safari smoke test (story 1, scenario 3) and +the SC-007 release check on the reference machine (OP3). + +## Answers + +Answers of the user during the build (2026-10-27). They record decisions that the plan did not cover. + +- **OP1 (memory per dof):** measured 63.3 bytes per dof for R1 through the session (target 75). The user confirmed OP1 on 2026-10-28, so the browser tasks (T039 to T045, T052) may start. +- **T013, session derive:** `Clone` is removed from `SolveSession` in `session.rs`, because `Problem` now holds the interpolator. +- **T016, scaling test:** the constraint rows grow with the node count in the scaling test (same density), because a stored row is 144 bytes and the constraint part is not small for a 30^3 grid. +- **T026, parity with the matrix solver (FR-025, SC-003):** the Rust numerics follow the Python solver. The curvature rows are only at nodes that are interior on all three axes. The border rows are centred second derivatives `[1, -2, 1]` on layers 1 and n-2. The gradient strike and dip basis uses the horizontal strike of the Python solver. The one-sided first-derivative border of loop-rs is not used. Reason: a centred second derivative gives a plane no penalty, and the Python results do not change (FR-019). +- **T035, column scaling:** `apply_scaling_matrix` is `True` by default in this repository. The native adapter ignores this setting (for any value) and logs one info line that column scaling is skipped. It does not raise `NotImplementedError` for it. Reason: native cannot honour scaling, and the default would make every default setup fail. Column scaling only changes the conditioning of the matrix, not the minimiser. The column-scaling row is removed from the unsupported-settings test (T029), and FR-026 keeps the other settings in that list (region mask, `use_regularisation_weight_scale`, constant-norm iterations, `interface_pair_mode="all"`, matrix-free regularisation). diff --git a/specs/001-low-memory-interpolator/research.md b/specs/001-low-memory-interpolator/research.md new file mode 100644 index 00000000..b70a5dbf --- /dev/null +++ b/specs/001-low-memory-interpolator/research.md @@ -0,0 +1,93 @@ +# Research: existing matrix-free interpolator work + +**Feature**: `001-low-memory-interpolator` | **Date**: 2026-10-27 + +This note records what exists today. It is input for `spec.md` and for the +plan. Facts come from the files that this note names. + +## 1. Where the work is + +| Place | Branch | State | +| --- | --- | --- | +| `~/dev/loop-rs/crates/interpolation-fd` | `lachlan/drillhole` (checked out), `main` | **Most complete.** 4548 lines in `interpolator.rs`. Has all three inequality solvers. | +| `~/dev/LoopAI/packages/interpolation-fd` | `feat/interpolation/constraint-api` | **Older port** of loop-rs commit `05a45ae`. 3141 lines. ADMM only. Adds an `Interpolator` trait adapter (`adapter.rs`). | +| `~/dev/LoopStructural/packages/loop_interpolation/.../loopsolver` | `master` | Python ADMM (`admm_method.py`, `admm_solver.py`, `admm_constant_norm.py`). This is the original that the Rust code ports. | + +Other worktrees were checked. The loop-rs worktrees `2d-plots` and +`interpolator-settings-page` do not change this crate in a way that matters for +this spec. The LoopAI worktree `feat+mesh+tet-isosurface-remesh` is about +meshes. + +**Use loop-rs as the source.** LoopAI has the trait adapter only. Take the +adapter idea from LoopAI. Take the numerical code from loop-rs. + +## 2. What loop-rs already has + +Source files: `cg.rs` (177 lines), `grid.rs` (396), `stencil.rs` (798), +`interpolator.rs` (4548), `python.rs` (710, PyO3). + +- **Operator.** The normal-equations operator `N = A^T W A` is never built. + `apply_normal_operator_into(x, scratch, out)` computes `out = N x` with + stencil sweeps. Each constraint family adds into the same `out`. +- **Constraints.** Value, gradient, normal, tangent, interface, value + inequality, pairwise inequality. Rows are trilinear. They are stored as small + records, not as a sparse matrix. +- **Regularisation.** Six curvature families (`dxx` to `dyz`), fused into one + 5x5x5 kernel for interior nodes (about 1.7 to 2 times faster). Centred + second-derivative border stencils ([1, -2, 1] on layers 1 and n-2). Directional (anisotropic) regularisation. +- **Solvers.** + - Preconditioned CG (Jacobi, or block-Jacobi along x). + - `solve_admm`: fixed-rho ADMM. The x-update is an inner CG. + - `solve_active_set_cg`: primal active set, with projected CG. + - `solve_hybrid`: a fixed ADMM warm-up, then the active-set polish. +- **Design notes in the code.** CG allocates its five work vectors once. + `Inequalities.md` (same folder) is the earlier design spec for the two + inequality methods. +- **Tests.** Unit tests in `interpolator.rs`, edge cases in + `tests/edge_cases.rs`, and Python parity tests in `tests_python/`. +- **Known gaps** (from its `TASKS.md`): no 2D support, no adaptive rho, no + column scaling. The cross-derivative weights differ from the current Python + package by a factor of 4 (`dxy`, `dxz`, `dyz`). The Python `rotation_xy` is + never applied, so the Rust rotation has no Python reference. + +## 3. Why this is not yet "low memory in a browser" + +These are the gaps between the existing code and the goal. They come from +reading the code and the notes. Nobody has measured them yet. + +1. **Memory is not measured.** Nothing in either repo reports bytes per + degree of freedom (dof). By count, plain CG holds `x`, `r`, `z`, `p`, `Ap`, + plus `rhs` and the Jacobi diagonal. That is 7 vectors of `f64`, about 56 + bytes per dof, before any scratch for the fused kernel. +2. **Precision is fixed at `f64`.** There is no `f32` path. An `f32` path + would halve the vector memory. +3. **The active-set polish uses a dense matrix.** The working-set Gram matrix + `Q_W Q_W^T` is factored with Cholesky (`active_set_cholesky`). Its size is + the square of the number of active rows. With many active rows this breaks + the low-memory goal. +4. **ADMM keeps extra vectors.** It keeps `z` and `u`, one entry per + inequality row each. It also keeps a copy of the right-hand side. +5. **Threading uses Rayon.** In a browser, Rayon needs shared memory, + `SharedArrayBuffer`, and cross-origin isolation. Many pages cannot meet + these. A single-thread path must work well. +6. **No WebAssembly build exists.** The crate builds as `cdylib` for PyO3 + only. Nothing is built for `wasm32`. +7. **No progress or cancel hooks.** A browser solve must not freeze the page. + It needs a worker, progress reports and cancel. +8. **`wasm32` memory limit.** A 32-bit module can address 4 GiB at most. In + practice browsers refuse growth earlier. The solver must know its budget + before it allocates. + +## 4. Candidate directions (for the plan, not for the spec) + +- Keep CG on the normal equations. Memory is O(dof + constraints). +- Reduce vectors: a CG variant that keeps fewer vectors, or store `r` and `z` + in the same buffer when the preconditioner is Jacobi. +- Offer `f32` vectors with `f64` accumulation for dot products. +- Replace the dense Gram factorisation in the active-set polish with a + matrix-free solve (for example, CG on the Gram operator) so that memory + stays linear in the number of active rows. +- Build for `wasm32-unknown-unknown` with `wasm-bindgen`. Run in a Web Worker. + Make Rayon optional. +- Keep the Python package as a user of the same core, through PyO3, so that + one core is tested in two places. diff --git a/specs/001-low-memory-interpolator/spec.md b/specs/001-low-memory-interpolator/spec.md new file mode 100644 index 00000000..77bd2f31 --- /dev/null +++ b/specs/001-low-memory-interpolator/spec.md @@ -0,0 +1,449 @@ +# Feature spec: Low-memory interpolator for the browser + +**Feature**: `001-low-memory-interpolator` | **Created**: 2026-10-27 | **Status**: Ready for plan (reviewed 2026-10-27) + +**Input**: The user wants an implicit-function interpolator that uses very +little memory and runs in a web browser. It works on a regular grid. It +never builds the system matrix. It handles inequality constraints (for +example, "horizon A is above horizon B"). Early work exists in `loop-rs` and +`LoopAI` (see `research.md`). + + + +## Context + +- Today the solver code that does this work is in two Rust projects outside + this repository. It runs as a native program or as a Python extension. It + does not run in a browser. Nobody has measured its memory use. +- The Python package `loop_interpolation` has a matrix-based version, and a + second version for inequality constraints. Both need much more memory. +- The goal is **not** a faster solver. The goal is a solver that **fits in a + small, known amount of memory**, so that it can run in a browser tab. +- The existing work is described in `research.md`. It is the starting point. + This spec does not copy its design. +- Words used in this spec: + - **dof**: one unknown value of the field, one for each grid node. + - **budget**: the maximum memory that the user lets the solver use. + - **inequality constraint**: a rule such as `field(A) >= field(B)` or + `field(P) <= 10`. + - **stage**: one named part of a solve, for example "setup", "solve" or + "check constraints". + - **desktop**: the native program, or the Python call, that uses the same + solver code outside a browser. + - **constraint row**: one data constraint or one inequality constraint. A + regularisation term is not a constraint row. + - **active constraint**: an inequality constraint that holds with equality + (within SC-004) at the answer. + - **reference solution**: the field that the matrix-based solver of + `loop_interpolation` gives for the same problem. For a problem that is too + big for that solver, it is the field of the same problem on the largest + grid that the matrix-based solver can solve, sampled at the same points. + +## User scenarios and tests + +### User story 1: Solve a model in a browser tab (priority: P1) + +A geologist opens a web page. They load contact points and orientation data. +They choose a grid. They press "Solve". The page shows a scalar field that +honours the data. No server does the work, and no install is needed. + +**Why this priority**: this is the whole purpose. Without it, nothing else in +this spec has value. + +**Independent test**: run problem R1 (see Success criteria) in a headless +browser. Compare the field with the reference solution. + +**Acceptance scenarios**: + +1. **Given** a grid of 1 million dof and a set of value and orientation + constraints, **when** the user solves in the browser, **then** the field is + returned and agrees with the reference result (SC-003). +2. **Given** the same problem, **when** the solve runs, **then** peak memory + stays inside the limit of SC-001. +3. **Given** a page with no cross-origin isolation, **when** the user solves, + **then** the solve still works on one thread. This holds in current + Chromium, Firefox and Safari. Automatic tests run in Chromium. Firefox and + Safari run a smoke test on a small grid. +4. **Given** the same problem, **when** it runs on the desktop, **then** it + gives the same field as in the browser (SC-003). +5. **Given** a problem with tangent constraints and interface constraints, + **when** the user solves, **then** the tangent vectors are normal to the + field gradient, and the two points of each interface have the same field + value, within the tolerance of SC-003. +6. **Given** a problem with each regularisation type (curvature, border, + directional), **when** the user solves with one type at a time, **then** the + field agrees with the reference solution within the tolerance of SC-003. +7. **Given** the same problem solved with 1 thread and with 4 threads, + **when** both solves end, **then** the two fields differ by at most the + tolerance of SC-003. +8. **Given** a solved field, **when** the user asks for the value and the + gradient at a point inside the grid, **then** the solver returns them. The + values agree with the reference solution within the tolerance of SC-003. + A point outside the grid gives an error. +9. **Given** a grid whose origin has a large coordinate value (for example + `5 000 000`), **when** the user solves, **then** the field agrees with the + field of the same problem moved to an origin of zero, within the tolerance + of SC-003. +10. **Given** input with a NaN, an infinite value, an array of the wrong + length, or a zero-length direction vector, **when** the user solves, + **then** the solver returns an error that names the constraint. It does + not return a field. + +### User story 2: Keep horizons in the right order (priority: P1) + +The geologist has two or more horizons. The field must keep them in order +everywhere, not only at the data points. The geologist adds inequality +constraints. The solver respects them. + +**Why this priority**: inequality constraints are the reason this solver +exists, and they are the part that normally costs the most memory. + +**Independent test**: use a problem where the unconstrained answer breaks the +order. Solve with the constraints. Check that the order holds at all constraint +points. Check peak memory. + +**Acceptance scenarios**: + +1. **Given** a value-bound constraint that the unconstrained answer breaks, + **when** the user solves, **then** the bound holds within the tolerance of + SC-004. +2. **Given** a pair constraint `field(A) - field(B) >= d` that the + unconstrained answer breaks, **when** the user solves, **then** it holds + within the same tolerance. +3. **Given** problem R3 (many active constraints), **when** the user solves, + **then** peak memory stays inside the limit of SC-011. Memory does not grow + with the **square** of the number of active constraints. +4. **Given** a constraint that is not active at the answer, **when** the user + solves, **then** the result equals the result with no such constraint. +5. **Given** a constraint that has a lower bound and an upper bound, + **when** the user solves, **then** both bounds hold within the tolerance of + SC-004. +6. **Given** constraints that cannot all hold, **when** the user solves, + **then** the solver stops and reports that the problem is infeasible. It + gives the 20 constraints with the largest violation (FR-015). It does not + return a field without a warning. + +### User story 3: The page stays alive during a long solve (priority: P2) + +A solve can take up to the time of SC-007. During it, the page must respond. The user +sees progress and can cancel. + +**Why this priority**: a frozen tab looks like a crash. The browser may show a +"page not responding" message. + +**Independent test**: start a long solve. Measure the delay of the page's +event loop. Cancel the solve and check the time to stop. + +**Acceptance scenarios**: + +1. **Given** a solve is running, **when** the user uses the page, **then** + the page responds in less than 100 ms (SC-006). +2. **Given** a solve is running, **when** the solver reports progress, **then** + the report gives the stage, the iteration, and the residual. The solver + sends a report at least once each second (FR-009). +3. **Given** a solve of R1 is running, **when** the user cancels, **then** it stops + in less than 1 s. The solver frees its memory. The page can start a new + solve. +4. **Given** the user cancels, **when** the user asks for the result, **then** + the solver can return the best field so far, marked as "not converged". + +### User story 4: Refuse a problem that does not fit (priority: P2) + +The user chooses a grid that is too big for the browser. The solver says so +before it starts. It does not crash the tab. + +**Why this priority**: a crash in a browser loses the user's page state, with +no clear reason. + +**Independent test**: ask for an estimate for grids of increasing size. Check +that the estimate is not lower than the real peak. Check that a too-large +request fails with a message. + +**Acceptance scenarios**: + +1. **Given** a grid and a set of constraints, **when** the user asks for a + memory estimate, **then** the solver returns the expected peak memory + without allocating the large arrays. +2. **Given** a budget, **when** the estimate is larger than the budget, + **then** the solve does not start. The message gives the estimate, the + budget, and the largest grid that would fit. +3. **Given** the browser refuses to grow memory during a solve, **when** this + happens, **then** the solver returns an error. It does not trap or abort + the module. + +### User story 5: Choose precision against memory (priority: P3) + +The user can trade accuracy for memory. A single-precision mode uses at most +60% of the memory of the default mode (scenario 1). + +**Why this priority**: it raises the largest grid that fits by about 1.7 +times. It is only useful +after stories 1 and 2 work. + +**Independent test**: solve the reference problem in both modes. Compare the +fields and the peak memory. + +**Acceptance scenarios**: + +1. **Given** single-precision mode, **when** the user solves the reference + problem, **then** peak memory is at most 60% of the double-precision peak. +2. **Given** single-precision mode, **when** the solve ends, **then** the + field agrees with the double-precision field within the tolerance of + SC-005. +3. **Given** a solve in either precision mode, **when** the solve ends, + **then** the reported residual differs from a double-precision calculation + of the same residual by at most 1e-6 relative error (FR-022). +4. **Given** single-precision mode cannot reach the requested tolerance, + **when** the solve ends, **then** the result says so. It does not claim + convergence. + +### User story 6: Use the same solver from Python (priority: P3) + +A Python user of `loop_interpolation` calls the same solver. This keeps one +solver, and it lets the Python test suite check the browser solver. + +**Why this priority**: one solver tested in two places is cheaper to keep +correct than two solvers. + +**Independent test**: run the same problem through the Python call and through +the browser build. Compare the fields. + +**Acceptance scenarios**: + +1. **Given** a problem built with the Python constraint classes, **when** the + user selects the low-memory solver, **then** the field agrees with the + matrix-based solver within the tolerance of SC-003. +2. **Given** the user does not install the new solver, **when** the user uses + `loop_interpolation`, **then** all existing behaviour stays the same + (FR-019). The existing test suite passes with no change. +3. **Given** a setting that the new solver does not support (for example a + model region mask), **when** the user selects the new solver, **then** the + call fails with an error that names the setting. It does not ignore the + setting (FR-026). +4. **Given** the same problem, **when** it runs through the Python call and + through the browser build, **then** the two fields agree within the + tolerance of SC-003 (FR-017). + +### Edge cases + +- The grid has one node along an axis. (Error: each axis needs at least 3 + nodes.) +- The step of the grid differs by more than a factor of 100 between two axes. + (The solver gives a warning.) +- A constraint point lies outside the grid. (It is dropped with a warning + that counts the dropped points.) +- The user closes the page or ends the worker during a solve. (All memory is + freed. No state is saved, see Out of scope.) +- An input value is NaN or infinite. (The solver refuses the input and names + the constraint.) +- All constraints are inequality constraints, with no value or orientation + data. The problem may have many answers. (The regularisation picks one.) +- A rotated grid. (Not supported. The caller rotates the data, FR-020. A + request with a rotation gives a clear error.) +- A solve with zero constraints. (Error: no constraints.) +- An inequality constraint whose lower bound is above its upper bound. (Error + that names the constraint.) +- A constraint weight of zero or less. (Error that names the constraint.) +- Two value constraints at one point with different values. (The solver + accepts them and gives a least-squares compromise. It adds a warning that + counts the conflicts.) +- A grid whose node count passes the largest count that the solver can index. + (Error that gives the node count and the largest count. The memory estimate + does not run first.) +- The user cancels during setup, before the first iteration. (The solve stops + in less than 1 s, as in FR-010.) +- A second solve starts in the same page while one solve runs. (The second + call is refused with an error that says a solve is running. The first solve + is not changed.) +- The solver reaches the iteration limit. (The result says "not converged" + with the final residual.) +- 2D grids. (Not in the first release. A 2D request gives a clear error.) +- A model region (a mask on the nodes), a column scaling, or a constant-norm + iteration from the Python package. (Not in the first release. Each gives a + clear error, FR-026.) + +## Requirements + +### Functional requirements + +- **FR-001**: The solver must never store the system matrix or the normal + matrix. +- **FR-023**: The solver must never store any other matrix whose size grows + with the number of dof times the number of neighbours of a node. +- **FR-002**: The memory that the solver needs must grow in a straight line + with the number of dof and the number of constraints. It must not grow with + the square of either. +- **FR-003**: The solver must support these constraints: value, gradient, + normal, tangent, and interface (equal value of two points). +- **FR-004**: The solver must support these inequality constraints: a bound on + the field at a point, and a bound on the difference of the field at two + points. A bound can be a lower bound, an upper bound, or both. +- **FR-005**: The solver must support the existing regularisation: curvature + in all six directions, border regularisation, and directional + regularisation. +- **FR-006**: The solver must run in a web browser page that has no + cross-origin isolation. In that page it runs on one thread. +- **FR-007**: The solver may use more threads when the platform has them. The + result must not depend on the thread count, within the tolerance of SC-003. +- **FR-008**: A solve must not block the page (story 3). +- **FR-009**: The solver must report progress (stage, iteration, residual) at + least once each second. +- **FR-010**: The solver must stop on request in less than 1 s, free its + memory, and be ready for a new solve. +- **FR-011**: The solver must give a memory estimate for a problem before it + allocates the large arrays (story 4). The estimate must not be lower than + the real peak. +- **FR-012**: The solver must accept a budget. It must refuse to start when the + estimate is over the budget. It must return an error, not abort, when an + allocation fails. +- **FR-013**: The solver must offer a single-precision mode and a + double-precision mode (story 5). +- **FR-022**: In both modes, the residual that decides convergence must agree + with a double-precision calculation of the same residual within 1e-6 + relative error. +- **FR-014**: Every result must say if the solve converged, how many + iterations it used, the final residual, and, for inequality problems, the + largest constraint violation. +- **FR-015**: For an infeasible inequality problem, the solver must say so and + return the 20 constraints with the largest violation at the stopped state + (story 2, scenario 5). The list is not a proof of a minimal conflict set. +- **FR-016**: The solver must return the field values at the grid nodes, and + must evaluate the field and its gradient at any point inside the grid. +- **FR-017**: The solver must give the same results from Python (story 6) + and from the browser, within the tolerance of SC-003. +- **FR-018**: Bad input (NaN, infinite, wrong array length, zero-length + vector for a direction) must give an error that names the constraint. It + must not give a wrong field. +- **FR-019**: The existing behaviour of `loop_interpolation` must not change + when the user does not select the new solver. +- **FR-020**: The solver must take all points and vectors in the coordinate + system of the grid. It must not change coordinates. The caller does all + coordinate transformation, including any rotation of the model. +- **FR-024**: The solver must keep the full precision of the grid origin that + the caller gives. +- **FR-021**: The user must be able to select the solver in place of the + existing finite difference interpolator of `loop_interpolation`, with a + solver setting (story 6). +- **FR-025**: The same constraints, weights and regularisation settings must + work with the new solver. +- **FR-026**: A setting that the solver does not support must give a clear + error that names the setting. The solver must not ignore it. + +### Protocol and data (if the feature changes them) + +- **Input**: a grid (origin, node counts, step; a rotation must be absent), a list of + constraints, regularisation weights, solver settings (precision, tolerance, + iteration limit, budget). +- **Output**: the field at the nodes, a convergence report, and a list of + warnings. +- **Data format**: constraints go in as flat numeric arrays, one array for each + field. They do not go in as text. The copies of the large arrays count in the + peak memory of SC-001. (How this is done goes in the plan.) + +### Key entities + +- **Problem**: one grid, many constraints, regularisation, and settings. +- **Constraint**: a small record (a point, a target, a weight). It is not a + matrix row. +- **Budget**: the maximum memory in bytes. +- **Memory estimate**: the expected peak bytes, split into the vectors, the + constraint records, and the working storage. +- **Reference solution**: see the definition in Context. +- **Solve report**: convergence flag, iteration count, residual, largest + violation, time, peak memory, and warnings. + +## Success criteria + + + +Reference problem **R1**: a grid of 100 x 100 x 100 nodes (1 million dof), 2 000 +value constraints, 500 orientation constraints, and the default +regularisation. Reference problem **R2**: the same grid, with 200 pair +inequality constraints that are active at the answer. +Reference problem **R3**: the same grid, with 20 000 pair +inequality constraints that are active at the answer. + +- **SC-001**: In double precision, R1 has a peak memory of 100 MB or less in + the browser, including the field that the solver returns. This is about 100 + bytes for each dof. +- **SC-002**: R2 has a peak memory of at most 20% more than R1. +- **SC-003**: The browser result, the desktop result, and the reference + result agree. The maximum difference is at most 1e-6 of the field range in + double precision. The solver stops at a residual tolerance that gives this + agreement. Results for different thread counts may differ in the last bits. +- **SC-004**: In R2, every inequality constraint holds with a violation of at + most 1e-4 of the field range. (A tighter limit of 1e-8 is for a later + phase, see Clarifications.) +- **SC-005**: In single precision, the maximum difference from double precision + is at most 1e-3 of the field range on R1. +- **SC-006**: During a solve of R1 in the browser, the delay of the page event + loop stays below 100 ms. +- **SC-007**: R1 solves in 60 s or less in Chromium on one thread, on the + reference machine. The reference machine is a laptop with 4 cores and 16 GB + of RAM. CI measures and reports the time. The release check uses the + reference machine. +- **SC-008**: On the desktop, a grid of 250 x 250 x 250 nodes (about 15.6 million dof) in + single precision, with a budget of 2 GB, solves with a peak memory of 1.5 GB + or less. (A request over the budget is refused, see story 4.) +- **SC-009**: The memory estimate is never lower than the measured peak, and + is at most 25% higher, for 10 grid sizes from 10^3 to 200^3 nodes, on the desktop. In Chromium the + same check runs for the grid sizes that fit in browser memory. +- **SC-010**: The module for the browser has a download size of 2 MB or less + after gzip compression. +- **SC-011**: R3 has a peak memory of at most 20% more than R1. +- **SC-012**: Peak memory for R1-type problems on 50^3, 100^3 and 150^3 nodes + fits a straight line against the dof count with R^2 of at least 0.99 + (FR-002). + +## Out of scope + +- Meshes that are not regular grids (tetrahedral or other). The existing + solvers for these stay as they are. +- Fault, fold and unconformity logic. This feature solves one scalar field. A + model builder calls it many times. +- Radial basis function and other non-grid interpolators. +- Drawing the field or an isosurface. (The viewer does this. See `geoviewer`.) +- A server or cloud mode. +- GPU solvers (WebGPU). +- Automatic choice of the best settings. +- Saving a solve to continue it later. +- Changes to the Python matrix-based solvers. +- Coordinate transformation and grid rotation. The caller does these (FR-020). +- Fold interpolators (they stay on the matrix-based solvers). +- A constraint tolerance of 1e-8. A later spec can add it. +- 2D grids, model region masks, column scaling, constant-norm iteration and + rotated grids. Each gives a clear error in this release. +- Browsers other than the current Chromium, Firefox and Safari. +- Phones and tablets. + +## Clarifications + + + +- 2026-10-27: The user asked for a spec for this work. The user said that the + work can go in `loop_interpolation` or in a new module of this library. The + user said that existing work is in `~/dev/LoopAI` or `~/dev/loop-rs`. + Result of the search: `loop-rs` is more complete (see `research.md`). + +- 2026-10-27: The user answered: "Plan it as a drop in for the loop + interpolation. If a web page calls it, the web page can deal with coordinate + transformation." Result: the solver is a drop-in choice for + `loop_interpolation` (FR-021). The caller does coordinate transformation + (FR-020). The input is flat arrays. + +- 2026-10-27: Review interview. Q: What peak memory for R1? A: 100 MB, keep + SC-001. Q: Solve time and machine? A: 60 s on a laptop with 4 cores and + 16 GB RAM (SC-007). Q: Which browsers? A: Current Chromium, Firefox and + Safari. Tests run in Chromium. Firefox and Safari get a smoke test (story 1, + scenario 3). Q: Two solves at once? A: The second is refused with an error. + Q: List of conflicting constraints? A: The 20 with the largest violation + (FR-015). Q: Exact 1e-8 tolerance? A: A later spec (Out of scope). + +- 2026-10-27: Second review interview. Q: Must the field be bit-for-bit equal + for any thread count? A: No. Agreement within the SC-003 tolerance is + enough (FR-007). Q: What is the SC-003 tolerance? A: 1e-6 of the field + range. Keep it. The solver stops at a residual tolerance that gives it. Q: + How many active constraints in R3? A: 20 000 (SC-011). diff --git a/specs/001-low-memory-interpolator/tasks.md b/specs/001-low-memory-interpolator/tasks.md new file mode 100644 index 00000000..9f9057b9 --- /dev/null +++ b/specs/001-low-memory-interpolator/tasks.md @@ -0,0 +1,589 @@ +# Tasks: Low-memory interpolator for the browser + +**Input**: `specs/001-low-memory-interpolator/` (`spec.md`, `plan.md`, `research.md`) +**Prerequisites**: `plan.md` (required), `spec.md` (required for the user stories) + + + +## Orchestrator notes + +- **Repository note**: this repository has no `.specify/` folder, no constitution, no `specs/README.md` and no Cargo workspace. The repo-fit check in `plan.md` replaces the constitution check. Build and test commands name the package with `--manifest-path packages/loop_interpolation_core/Cargo.toml`. Do not set `CARGO_TARGET_DIR`. +- **Worktree**: implement this spec in its own worktree. Do not switch the branch of the main checkout. +- **Rules for each sub agent**: read `plan.md` (sections D1 to D8 and the open points); change only the files in the task `Files` list; for a test task, run the test and see it fail (or be skipped, where the task says so); write comments and docs in ASD-STE100 Simplified Technical English. Use `loop-rs` (`~/dev/loop-rs/crates/interpolation-fd`, commit `bff51342da8ebff7ca3c87e07c1d4fbd31279c8b`) only as the source named in the task. +- **Tiers**: `[S]` = send to a small, fast model. `[M]` = send to the standard model. If an `[S]` task fails twice, send it to `[M]`. The role names in `.agents/model-policy.json` are `worker-S` and `worker-M`. +- **Waves**: 21 waves. Start a wave when all `Needs` of its tasks are done. Run the tasks of a wave in parallel. A task with `Files: none` runs alone. +- **After each wave**: run `cargo check --manifest-path packages/loop_interpolation_core/Cargo.toml` (once the crate exists) and `python -m pytest packages/loop_interpolation/tests -q -x` after a wave that changed Python files. Fix a failure before the next wave. +- **Checkpoints**: + - After task T011 (end of Phase 2): the ported unit tests pass with and without `--features parallel`, and `measurements.md` has `Baseline` and `After table cuts`. + - After task T020: `bytes_per_dof` is at most 75. If it is above 95, stop and ask the user (OP1). Between 75 and 95, ask the user before Phase 7. + - After task T023 (end of Phase 4): `cargo test` passes with all core tests. + - After task T038: the Python suite passes in the venv with the core installed, and without it. + - After task T045: browser tests pass and the size is at most 2 MB gzip. If the size is above, stop (OP2). +- **Blocked tasks**: do not start a task with `Blocked by` before the user confirms the open point. +- **Before done**: `cargo fmt --check`, `cargo clippy -- -D warnings`, `cargo test` (all with `--manifest-path packages/loop_interpolation_core/Cargo.toml`), and `python -m pytest packages/loop_interpolation/tests -q`. +- **Commits**: Conventional Commits, `type(scope): description`, for example `feat(loop-interpolation-core): add solve session`. Scope the commits to the package path so release-please counts them for the right package. Do not commit unless the user asks. + +## Phase 1: Setup + +Package scaffold, release and workspace settings. Rust is new in this repository. + +- [x] T001 [P] [Setup] [S] Add the new package to the release-please files. + - Do: Add package path `packages/loop_interpolation_core`, `release-type` `python`, `component` `loop-interpolation-core`. + - Do: Add the same path to `exclude-paths` of the root package `.` (fix of 2026-09-27). + - Do: Add `"packages/loop_interpolation_core": "0.0.0"` to the manifest. + - Files: `.release-please-config.json`, `.release-please-manifest.json` + - Needs: none + - Wave: 1 + - Done when: `python -c "import json; c=json.load(open('.release-please-config.json')); assert 'packages/loop_interpolation_core' in c['packages']; assert 'packages/loop_interpolation_core' in c['packages']['.']['exclude-paths']; m=json.load(open('.release-please-manifest.json')); assert 'packages/loop_interpolation_core' in m"` exits 0. +- [x] T002 [P] [Setup] [S] Keep the Rust package out of the uv workspace. Ignore build output. + - Do: In `[tool.uv.workspace]` keep `members = ["packages/*"]` and add `exclude = ["packages/loop_interpolation_core"]`. + - Do: In `.gitignore` add `packages/loop_interpolation_core/pkg/` and `packages/loop_interpolation_core/tests_web/node_modules/`. `target/` is already ignored. + - Files: `pyproject.toml`, `.gitignore` + - Needs: none + - Wave: 1 + - Done when: `python -c "import tomllib; d=tomllib.load(open('pyproject.toml','rb')); assert 'packages/loop_interpolation_core' in d['tool']['uv']['workspace']['exclude']"` exits 0. +- [x] T003 [P] [Setup] [S] Create `Cargo.toml` and an empty `lib.rs` for the core crate. + - Do: Package name `loop-interpolation-core`, version `0.0.0`, edition `2024`. + - Do: `[lib] name = "loop_interpolation_core"`, `crate-type = ["cdylib", "rlib"]`. + - Do: Optional dependencies: `rayon = "1.10"`, `pyo3 = { version = "0.29", features = ["extension-module", "abi3-py39"] }`, `numpy = "0.29"`, `wasm-bindgen = "0.2"`. + - Do: Features: `parallel = ["dep:rayon"]`, `python = ["dep:pyo3", "dep:numpy"]`, `wasm = ["dep:wasm-bindgen"]`. `default = []`. + - Do: No `serde`, no `regex` (SC-010). + - Do: Add `[profile.wasm-size]` with `inherits = "release"`, `opt-level = "z"`, `lto = true`, `codegen-units = 1`, `panic = "abort"`. + - Do: `src/lib.rs` has only a crate doc comment. + - Files: `packages/loop_interpolation_core/Cargo.toml`, `packages/loop_interpolation_core/src/lib.rs` + - Needs: none + - Wave: 1 + - Done when: `cargo check --manifest-path packages/loop_interpolation_core/Cargo.toml` exits 0. +- [x] T004 [P] [Setup] [S] Create `pyproject.toml` (maturin) and a README skeleton for the core package. + - Do: Build backend `maturin>=1,<2`. Project name `loop-interpolation-core`, `requires-python = ">=3.9"`, dependency `numpy`. + - Do: `[tool.maturin]`: `features = ["python"]`, `module-name = "loop_interpolation_core"`. + - Do: README (ASD-STE100): purpose, link to `specs/001-low-memory-interpolator/spec.md`, and the line `Source: loop-rs crates/interpolation-fd, commit bff51342da8ebff7ca3c87e07c1d4fbd31279c8b, branch lachlan/drillhole`. + - Files: `packages/loop_interpolation_core/pyproject.toml`, `packages/loop_interpolation_core/README.md` + - Needs: none + - Wave: 1 + - Done when: `python -c "import tomllib; d=tomllib.load(open('packages/loop_interpolation_core/pyproject.toml','rb')); assert d['tool']['maturin']['module-name']=='loop_interpolation_core'"` exits 0. +- [x] T005 [P] [Found] [M] Write the fixture generator and the 20x20x20 fixture file (reference solution from the matrix solver). + - Do: Read `plan.md` section D8 (fixture format) first. + - Do: Build a 20x20x20 `FiniteDifferenceInterpolator` problem with value, gradient, normal, tangent and interface data (fixed seed). Solve it with `solver="cg"` and `tol=1e-10`. This field is the reference solution. + - Do: Write the flat arrays in the D8 order. Write regularisation and interpolation weights in core meaning: the cross-derivative weights `dxy`, `dxz`, `dyz` are divided by 4 (see `StructuredGrid.get_operators`). Read `loop-rs/crates/interpolation-fd/tests_python/test_drop_in_replacement.py` for the mapping of gradient, normal and tangent data. + - Do: Record the final mapping in the module doc string. T035 copies it. + - Files: `packages/loop_interpolation_core/fixtures/make_fixture.py`, `packages/loop_interpolation_core/fixtures/fixture_20.bin` + - Needs: none + - Wave: 1 + - Done when: `python -I packages/loop_interpolation_core/fixtures/make_fixture.py --check` exits 0. It reads `fixture_20.bin`, re-solves with the Python matrix solver (`solver="cg"`) and the max difference is at most 1e-6 of the field range. + +## Phase 2: Foundation (port the loop-rs code, measure memory) + +Phase A of the plan. Source: `~/dev/loop-rs/crates/interpolation-fd` at commit bff51342da8ebff7ca3c87e07c1d4fbd31279c8b. All tasks use the ported unit tests as the safety net. + +- [x] T006 [P] [Found] [M] Copy `cg.rs`, `grid.rs`, `stencil.rs`, `interpolator.rs` and `tests/edge_cases.rs` from loop-rs. Declare the modules in `lib.rs`. + - Do: Copy the files unchanged. Do not copy `main.rs`, `python.rs`, `tests_python/`, `Inequalities.md`, or build output (`*.pyd`, `__pycache__`). + - Do: In `lib.rs` declare `pub mod cg; pub mod grid; pub mod interpolator; pub mod stencil;`, copy the `PAR_MIN_LEN` constant and the `pub use` lines. Use the crate name `loop_interpolation_core` in `tests/edge_cases.rs`. + - Do: Keep `rayon` in these files for now (feature `parallel`). T008 removes it. + - Files: `packages/loop_interpolation_core/src/cg.rs`, `packages/loop_interpolation_core/src/grid.rs`, `packages/loop_interpolation_core/src/stencil.rs`, `packages/loop_interpolation_core/src/interpolator.rs`, `packages/loop_interpolation_core/tests/edge_cases.rs`, `packages/loop_interpolation_core/src/lib.rs` + - Needs: T003 + - Wave: 2 + - Done when: `cargo test --manifest-path packages/loop_interpolation_core/Cargo.toml --features parallel` passes (all ported unit tests and `edge_cases`). +- [x] T007 [P] [Found] [M] Remove grid rotation and the `serde` attribute (FR-020). + - Do: Delete field `rotation`, `with_rotation_matrix`, `with_rotation_xy_degrees`, `to_local`, `direction_to_local`, the `serde` `cfg_attr` line, and every test or call site that uses them. + - Do: Do not change any other behaviour. + - Files: `packages/loop_interpolation_core/src/grid.rs`, `packages/loop_interpolation_core/src/interpolator.rs`, `packages/loop_interpolation_core/tests/edge_cases.rs` + - Needs: T006 + - Wave: 3 + - Done when: `cargo test --manifest-path packages/loop_interpolation_core/Cargo.toml --features parallel` passes and `grep -rnE "rotation|to_local|direction_to_local|serde" packages/loop_interpolation_core/src packages/loop_interpolation_core/tests` prints nothing. +- [x] T008 [Found] [M] Make `rayon` optional with a one-thread shim (FR-006). + - Do: Create `src/par.rs`. With feature `parallel` it re-exports `rayon::prelude::*`. + - Do: Without the feature it defines traits that give the same method names on std iterators and slices: `par_iter`, `par_iter_mut`, `par_chunks_mut`, `into_par_iter`, `with_min_len` (returns `self`), and a `fold` that takes the rayon form `(identity_fn, op)`. These are the only rayon methods used (checked by `grep`). + - Do: Replace each `use rayon::prelude::*;` with `use crate::par::*;`. Add `pub(crate) mod par;` to `lib.rs`. + - Do: The result of every function must not change. + - Files: `packages/loop_interpolation_core/src/par.rs`, `packages/loop_interpolation_core/src/cg.rs`, `packages/loop_interpolation_core/src/stencil.rs`, `packages/loop_interpolation_core/src/interpolator.rs`, `packages/loop_interpolation_core/src/lib.rs` + - Needs: T007 + - Wave: 4 + - Done when: `cargo test --manifest-path packages/loop_interpolation_core/Cargo.toml` (no features) passes, `cargo test --manifest-path packages/loop_interpolation_core/Cargo.toml --features parallel` passes, and `cargo check --manifest-path packages/loop_interpolation_core/Cargo.toml --target wasm32-unknown-unknown` exits 0. +- [x] T009 [P] [Found] [M] Add the counting allocator and the baseline memory test. Record the baseline. + - Do: `tests/common/mod.rs`: a `#[global_allocator]` wrapper of `std::alloc::System` with atomics `CURRENT` and `PEAK`, and functions `reset_peak()`, `current()`, `peak()`. + - Do: `tests/memory.rs`: ignored test `baseline_r1` builds problem R1 (100x100x100 grid, 2000 value and 500 normal constraints, default regularisation) with the ported API, calls `solve(5000, 1e-8)`, and prints `bytes_per_dof = peak / 1e6`. Also a normal test `baseline_small` on 30^3 that only prints. + - Do: Create `measurements.md` with the table heading `Baseline` and the value. + - Files: `packages/loop_interpolation_core/tests/common/mod.rs`, `packages/loop_interpolation_core/tests/memory.rs`, `specs/001-low-memory-interpolator/measurements.md` + - Needs: T008 + - Wave: 5 + - Done when: `cargo test --manifest-path packages/loop_interpolation_core/Cargo.toml --release --test memory -- --ignored --nocapture baseline_r1` prints `bytes_per_dof=` and the same value is in `specs/001-low-memory-interpolator/measurements.md` under the heading `Baseline`. +- [x] T010 [P] [Found] [M] Apply the table cuts of D4 inside `interpolator.rs`. Add `table_bytes()` (FR-001, FR-023). + - Do: Store `shell_row_lookup`, `shell_row_indices` and `shell_output_indices` as `u32` (the top value is the empty marker). + - Do: Add `pub fn table_bytes(&self) -> usize`: the bytes held by the interpolator's own tables (lookup, indices, directional weights, combined kernel). Add a test in `tests/memory.rs` that asserts `table_bytes() <= 12 * dof` for a 40^3 grid. + - Do: Add the heading `After table cuts` to `measurements.md` with the new value. Do not change the CG code here (T020 rewrites it). + - Files: `packages/loop_interpolation_core/src/interpolator.rs`, `packages/loop_interpolation_core/tests/memory.rs`, `specs/001-low-memory-interpolator/measurements.md` + - Needs: T009 + - Wave: 6 + - Done when: `cargo test --manifest-path packages/loop_interpolation_core/Cargo.toml --release --test memory -- --ignored --nocapture baseline_r1` prints a value at least 4 bytes per dof lower than `Baseline`, and `cargo test --manifest-path packages/loop_interpolation_core/Cargo.toml` passes. +- [x] T011 [P] [Found] [M] Add the module files and the type and function stubs of D8. + - Do: Read `plan.md` section D8. Create every type there with the exact fields and derive `Debug, Clone` (and `PartialEq` where D8 says). Implement `Display` and `std::error::Error` for `CoreError`. + - Do: Every function in D8 returns `Err(CoreError::Unsupported { setting: "not implemented".into() })` or, if it cannot return `Result`, calls `unimplemented!()`. `Settings::default()` has the real default values of D8. + - Do: `scalar.rs`, `python.rs`, `wasm.rs` hold a doc comment only. In `lib.rs` add `pub mod` lines for the new modules, `#[cfg(feature = "python")] mod python;`, `#[cfg(feature = "wasm")] mod wasm;` and `pub use` lines for the public types. + - Files: `packages/loop_interpolation_core/src/error.rs`, `packages/loop_interpolation_core/src/settings.rs`, `packages/loop_interpolation_core/src/report.rs`, `packages/loop_interpolation_core/src/field.rs`, `packages/loop_interpolation_core/src/memory.rs`, `packages/loop_interpolation_core/src/session.rs`, `packages/loop_interpolation_core/src/problem.rs`, `packages/loop_interpolation_core/src/admm.rs`, `packages/loop_interpolation_core/src/scalar.rs`, `packages/loop_interpolation_core/src/python.rs`, `packages/loop_interpolation_core/src/wasm.rs`, `packages/loop_interpolation_core/src/lib.rs` + - Needs: T008 + - Wave: 5 + - Done when: `cargo check --manifest-path packages/loop_interpolation_core/Cargo.toml` exits 0 and `cargo test --manifest-path packages/loop_interpolation_core/Cargo.toml` still passes. + +## Phase 3: User story 1 core - flat input, memory estimate, sessions + +Phase B of the plan, first part. Tests before code. + +- [x] T012 [P] [US1] [M] Test: bad input gives an error that names the constraint (FR-018, story 1 scenario 10, edge cases). + - Do: One test for each case. Each case builds a `Problem` and calls one `add_*` method. Check the error variant and that `constraint` is like `value[3]` (family name and row). + - Do: Cases: NaN in a point; infinite value; array of the wrong length; zero-length vector for `add_gradient`, `add_normal`, `add_tangent`; weight 0 and weight -1; `lower > upper` for `add_value_bound` and `add_pair_bound`; grid with 2 nodes on one axis (`GridTooSmall`); grid whose node count is above `MAX_NODES` (`GridTooLarge`, no allocation); `start` with zero constraints (`NoConstraints`). + - Do: Warnings (not errors): a point outside the grid is dropped and `warnings()` has one text with the count; two value constraints at one point with different values give a warning with the count; a step ratio above 100 gives a warning. + - Do: A rotation cannot be given: there is no API for it. Add a doc comment that says so. + - Files: `packages/loop_interpolation_core/tests/input_validation.rs` + - Needs: T011 + - Wave: 6 + - Done when: `cargo test --manifest-path packages/loop_interpolation_core/Cargo.toml --test input_validation` compiles and fails (the stubs return errors). +- [x] T013 [P] [US1] [M] Implement `Problem::new` and the flat `add_*` methods with validation. + - Do: Use the ported `FiniteDifferenceInterpolator` methods for the maths. Validate first, then call the ported method. Do not add large allocations. + - Do: Conflict count: compare each new value constraint with earlier ones at the same point with a hash of the point bits. Use a `HashMap` only if the count is below 1 000 000; above this skip the check and add a warning that says it was skipped. + - Do: `MAX_NODES` is `isize::MAX as u64 / 8`. Use `u128` for the product of the counts. + - Files: `packages/loop_interpolation_core/src/problem.rs` + - Needs: T012 + - Wave: 7 + - Done when: `cargo test --manifest-path packages/loop_interpolation_core/Cargo.toml --test input_validation` passes. +- [x] T014 [P] [Found] [S] Test: `MemTracker` counts bytes, tracks the peak, and refuses over budget. + - Do: Add `#[cfg(test)] mod tests` to `memory.rs` (the stub body stays). + - Do: Tests: `try_zeroed::(10)` raises `current` by 80 and `peak` to 80; `release(80)` sets `current` to 0 and keeps `peak` 80; with `budget = Some(79)` a request for 80 bytes gives `OverBudget` and nothing is allocated; `try_zeroed::(usize::MAX / 16)` gives `OutOfMemory`, not an abort. + - Files: `packages/loop_interpolation_core/src/memory.rs` + - Needs: T011 + - Wave: 6 + - Done when: `cargo test --manifest-path packages/loop_interpolation_core/Cargo.toml --lib memory::` compiles and fails. +- [x] T015 [P] [Found] [S] Implement `MemTracker` (`try_zeroed`, `release`, `peak`, `current`) with `Vec::try_reserve_exact` (FR-012). + - Do: `try_zeroed(&mut self, n: usize) -> Result, CoreError>`: check the budget with `u64` maths, call `try_reserve_exact`, then fill with `T::default()`. + - Files: `packages/loop_interpolation_core/src/memory.rs` + - Needs: T014 + - Wave: 7 + - Done when: `cargo test --manifest-path packages/loop_interpolation_core/Cargo.toml --lib memory::` passes. +- [x] T016 [P] [US4] [M] Test: memory estimate, budget refusal, and no big allocation before the check (story 4, FR-011, FR-012). + - Do: Use `tests/common/mod.rs` (counting allocator). Add `mod common;`. + - Do: `estimate_memory` for 100^3 with R1 data gives `total` between 40 MB and 100 MB, and all parts are above 0. It scales in a straight line: the ratio `total(60^3) / total(30^3)` is between 7.5 and 8.5. The call allocates less than 1 MB (use `reset_peak` and `peak`). + - Do: `Problem::new` on a 200^3 grid allocates less than 1 MB. + - Do: `start` with `budget_bytes = Some(1_000_000)` on 100^3 returns `OverBudget`; `max_nodes_per_axis` is at least 1 and a cubic grid of that size has an estimate at most the budget; the call allocates less than the budget. + - Do: Constraint bytes are counted: adding 100 000 pair bounds raises `constraints` by at least 100 000 * 200 bytes. + - Files: `packages/loop_interpolation_core/tests/memory_estimate.rs` + - Needs: T013, T009, T015 + - Wave: 8 + - Done when: `cargo test --manifest-path packages/loop_interpolation_core/Cargo.toml --test memory_estimate` compiles and fails. +- [x] T017 [P] [US4] [M] Implement the memory estimate, the budget check and `max_grid_for_budget`. + - Do: `memory.rs`: `pub fn estimate(counts: &ProblemCounts, s: &Settings) -> MemoryEstimate` with arithmetic from the table in `plan.md` D4. Add 10 percent margin. `ProblemCounts` holds `dof`, counts of each constraint family, `uses_directional`. + - Do: `pub fn max_grid_for_budget(counts: &ProblemCounts, s: &Settings, budget: u64) -> usize`: largest `n` such that an `n x n x n` grid fits. Binary search. No allocation. + - Do: `Problem::estimate_memory` fills `ProblemCounts` and calls `estimate`. `Problem::start` calls it first and returns `OverBudget` when `budget_bytes` is set and the total is above it. + - Files: `packages/loop_interpolation_core/src/memory.rs`, `packages/loop_interpolation_core/src/problem.rs` + - Needs: T016 + - Wave: 9 + - Done when: `cargo test --manifest-path packages/loop_interpolation_core/Cargo.toml --test memory_estimate` passes. +- [x] T018 [P] [US4] [M] Build the grid tables lazily and through `MemTracker`, not in the constructor. + - Do: `FiniteDifferenceInterpolator::new` must not allocate any `dof`-sized vector. Move `build_shell_indices` and the combined kernel to `pub(crate) fn prepare(&mut self, tracker: &mut MemTracker) -> Result<(), CoreError>`. Call `prepare` at the start of `solve_with` in the ported code so the ported tests still pass. + - Do: Change every `vec![0.0; dof]`-type allocation in `rhs`, `diagonal`, `scratch` and the border buffers to `tracker.try_zeroed`. Keep the old infallible methods as thin wrappers for the ported tests. + - Files: `packages/loop_interpolation_core/src/interpolator.rs` + - Needs: T015, T010, T016 + - Wave: 9 + - Done when: `cargo test --manifest-path packages/loop_interpolation_core/Cargo.toml` passes (all ported tests) and `cargo test --manifest-path packages/loop_interpolation_core/Cargo.toml --test memory_estimate -- problem_new` passes. +- [x] T019 [P] [US1] [M] Test: the CG session equals the one-shot solve, can be cancelled, reports progress, and frees memory (story 3 core, FR-009, FR-010, FR-014). + - Do: Problem: 30^3, value and normal constraints (fixed data in the test). + - Do: (1) Repeat `step(7)` until `Done`: the field differs from the ported `solve(5000, 1e-10)` field by at most 1e-8 of the range. + - Do: (2) Each `Running(progress)` has `stage == Solve`, a growing `iteration` and a falling `residual` (over 5 steps). + - Do: (3) Drop the session after 3 steps: `common::current()` returns to within 64 KB of the value before `start`. + - Do: (4) Drop right after `start` (cancel during setup): same memory check. + - Do: (5) `finish()` after 3 steps returns a field and a report with `converged == false`. `best_so_far()` gives the same. + - Do: (6) `max_iter = 3` gives `Done` with `converged == false` and the final residual in the report. + - Do: (7) The report has `peak_bytes > 0` and `iterations` equal to the count of CG iterations. + - Files: `packages/loop_interpolation_core/tests/session_cg.rs` + - Needs: T013, T009, T015 + - Wave: 8 + - Done when: `cargo test --manifest-path packages/loop_interpolation_core/Cargo.toml --test session_cg` compiles and fails. +- [x] T020 [US1] [M] Implement the CG state machine and `SolveSession` (`start`, `step`, `best_so_far`, `finish`). Keep 4 full vectors plus the inverse diagonal. + - Do: Read `plan.md` D3, D4 and D8. + - Do: `cg.rs`: `pub struct CgState { x, r, p, ap, inv_diag }` and `pub fn step(&mut self, op: &mut dyn FnMut(&[f64], &mut [f64]), iters: usize) -> CgStatus`. Do not store `z`: use `r.z = sum(r*r*inv_diag)`. Do not store `rhs`: build `r = rhs - N x0` straight into `r`. Dot products add in `f64`. + - Do: `session.rs`: `SolveSession` owns the `Problem` (so it needs no lifetime). `Problem::start(self, s)` checks the budget, calls `prepare`, allocates through `MemTracker`, returns the session in stage `Setup`. + - Do: `step` runs at most `max_iters` iterations and returns `Step::Running` or `Step::Done`. Dropping the session frees all memory. + - Do: Add the line with the new value to `measurements.md` under `After CG state machine`. + - Do: If the value is above 95, stop and report to the orchestrator (OP1). + - Files: `packages/loop_interpolation_core/src/cg.rs`, `packages/loop_interpolation_core/src/session.rs`, `packages/loop_interpolation_core/src/problem.rs` + - Needs: T019, T017, T018 + - Wave: 10 + - Done when: `cargo test --manifest-path packages/loop_interpolation_core/Cargo.toml --test session_cg` passes and `cargo test --manifest-path packages/loop_interpolation_core/Cargo.toml --release --test memory -- --ignored --nocapture baseline_r1` prints a value of at most 75 for `bytes_per_dof` (target of D4). + +## Phase 4: User story 2 - inequality constraints (ADMM) + +Phase B, second part. The active-set polish is not in this plan (see plan.md D5). + +- [x] T021 [P] [US2] [M] Test: inequality constraints hold, inactive constraints change nothing, infeasible problems report 20 rows (story 2, FR-004, FR-015). + - Do: Problem: 20^3 with data that makes the free answer break the order. + - Do: (1) Value bound: violation at most 1e-4 of the range (SC-004). (2) Pair bound `A - B >= d`: same. (5) Lower and upper bound on one row: both hold. + - Do: (4) A bound that is not active: the field differs from the field without it by at most 1e-8 of the range. + - Do: (6) Two bounds that cannot hold together: `solve` returns `Infeasible` with at most 20 rows, sorted by falling violation. + - Do: Every `Running` progress has `max_violation`. The final report has `max_violation`. + - Do: Check that `Hybrid` or `ActiveSet` methods do not exist in `Settings`. + - Files: `packages/loop_interpolation_core/tests/session_admm.rs` + - Needs: T020 + - Wave: 11 + - Done when: `cargo test --manifest-path packages/loop_interpolation_core/Cargo.toml --test session_admm` compiles and fails. +- [x] T022 [P] [US2] [M] Implement ADMM as a state machine over the CG state. Add stall detection for infeasible problems. + - Do: Port `solve_admm` of `interpolator.rs` into `admm.rs`: outer loop `z = clip(Q x + u, lower, upper)`, `u += Q x - z`, inner CG on `N + rho Q^T Q`. The state holds `z` and `u` (one entry per inequality row) and one full-size vector for the right-hand side. No dense matrix. + - Do: Make the helper methods the state needs `pub(crate)` in `interpolator.rs` (`accumulate_inequality_normal`, `accumulate_inequality_transpose`, `inequality_diagonal`, `inequality_bounds`). + - Do: `session.rs`: if the problem has inequality rows, the session runs ADMM; else plain CG. Stage `CheckConstraints` is reported at each outer iteration end. + - Do: Stall test: if the primal residual does not fall by 1 percent in 20 outer iterations and rows still violate by more than the tolerance, return `Infeasible` with the 20 largest violations. + - Files: `packages/loop_interpolation_core/src/admm.rs`, `packages/loop_interpolation_core/src/session.rs`, `packages/loop_interpolation_core/src/interpolator.rs` + - Needs: T021 + - Wave: 12 + - Done when: `cargo test --manifest-path packages/loop_interpolation_core/Cargo.toml --test session_admm` passes. +- [x] T023 [P] [US2] [M] Test: memory with many active constraints stays near R1 (story 2 scenario 3, SC-002, SC-011, FR-002). + - Do: Make active pair bounds by solving once without bounds, then bounding pairs at their free difference. + - Do: Add the ignored tests `r2` (100^3, 200 pairs) and `r3` (100^3, 20 000 pairs). Print peak bytes. Add the numbers to `measurements.md` only if the user runs them. + - Files: `packages/loop_interpolation_core/tests/scale_inequality.rs` + - Needs: T022 + - Wave: 13 + - Done when: `cargo test --manifest-path packages/loop_interpolation_core/Cargo.toml --release --test scale_inequality` passes (50^3, 2 500 active pair bounds, peak at most 1.2 times the peak without them). The ignored tests `r2` and `r3` run with `--ignored` and print the peaks. + +## Phase 5: User story 1 - constraint families, evaluation, fixture + +These tasks edit files that ADMM also edits, so they follow Phase 4. + +- [x] T024 [P] [US1] [M] Test: tangent and interface properties, point evaluation, large origin, thread count (story 1 scenarios 5, 7, 8, 9; FR-007, FR-016, FR-024). + - Do: (5) Tangent vectors: the dot product of each tangent with the field gradient at its point, divided by the gradient length, is below 1e-3. Interface pairs: the two field values differ by at most 1e-4 of the range. + - Do: (8) `Field::evaluate` at the nodes equals the field. `evaluate_gradient` matches central differences of `evaluate` within 1e-3 of the largest gradient. A point outside the grid gives `PointOutsideGrid`. + - Do: (9) Shift the grid origin and all points by 5 000 000 in each axis: the field differs from the unshifted field by at most 1e-6 of the range. + - Do: (7) With `#[cfg(feature = "parallel")]`: run in a rayon pool with 1 thread and with 4 threads (`ThreadPoolBuilder`). The fields differ by at most 1e-6 of the range. + - Files: `packages/loop_interpolation_core/tests/constraint_families.rs` + - Needs: T020 + - Wave: 11 + - Done when: `cargo test --manifest-path packages/loop_interpolation_core/Cargo.toml --test constraint_families` compiles and fails. +- [x] T025 [P] [US1] [M] Implement `Field` and `evaluate`, `evaluate_gradient`. Fix any failure of the family tests. + - Do: `Field` evaluate uses the ported `evaluate` and `evaluate_gradient` and the grid. Check each point is inside the grid first. + - Do: For the large-origin case compute `(point - origin) / step` in `f64` before any cast. + - Files: `packages/loop_interpolation_core/src/field.rs`, `packages/loop_interpolation_core/src/grid.rs`, `packages/loop_interpolation_core/src/interpolator.rs`, `packages/loop_interpolation_core/src/session.rs` + - Needs: T024, T022 + - Wave: 13 + - Done when: `cargo test --manifest-path packages/loop_interpolation_core/Cargo.toml --test constraint_families` passes (with `--features parallel` also). +- [x] T026 [P] [US1] [M] Test: the Rust solver agrees with the matrix-solver fixture (story 1 scenario 4, SC-003). + - Do: Read `fixtures/fixture_20.bin` (format in D8, little endian, no crates). Build the `Problem`, solve with `tol = 1e-10`, compare with `expected_field`. + - Files: `packages/loop_interpolation_core/tests/parity_fixture.rs` + - Needs: T005, T020 + - Wave: 11 + - Done when: `cargo test --manifest-path packages/loop_interpolation_core/Cargo.toml --test parity_fixture` passes. If the max difference is above 1e-6 of the range, the task is not done: report the mapping error to the orchestrator. + +## Phase 6: User story 6 - Python drop-in + +Phase D of the plan. It needs only phases 3 to 5. The Python tests skip when the core is not installed. + +- [x] T027 [P] [US6] [M] Test: parity with the matrix solver for each constraint family, each regularisation, and node order (story 6 scenario 1, FR-025, SC-003). + - Do: Start the file with `pytest.importorskip("loop_interpolation_core")`. + - Do: On a 30^3 `StructuredGrid` problem, for value, gradient, normal, tangent, interface, `dxx`, `dyy`, `dzz`, `dxy`, `dxz`, `dyz`, border, and directional regularisation, one family at a time: solve with `solver="cg"` and with `solver="native"`. The fields differ by at most 1e-6 of the range. + - Do: A node-order test: a field `f(x,y,z) = x + 2y + 3z` fixed with value constraints gives the same `interpolator.c` order as the matrix solver. + - Do: Use the existing `InterpolatorBuilder` (`use_solver("native")`, then `setup_interpolator`, `solve`). Use `tests/fixtures/` helpers where they exist. + - Files: `packages/loop_interpolation/tests/test_native_parity.py` + - Needs: T004 + - Wave: 2 + - Done when: `python -m pytest packages/loop_interpolation/tests/test_native_parity.py -q` collects the tests and skips all (reason: core not installed). After T038 it passes with 0 skipped. +- [x] T028 [P] [US6] [M] Test: inequality problems agree with the Python ADMM solver (story 6 scenario 1, SC-004). + - Do: Start with `pytest.importorskip`. Compare `solver="native"` with `solver="admm"` on a 20^3 problem with value bounds and with pair bounds (use `inequality_pairs`). The constraint violation is at most 1e-4 of the range in both, and the two fields differ by at most 1e-3 of the range. + - Files: `packages/loop_interpolation/tests/test_native_inequality.py` + - Needs: T004 + - Wave: 2 + - Done when: `python -m pytest packages/loop_interpolation/tests/test_native_inequality.py -q` collects the tests and skips all. +- [x] T029 [P] [US6] [S] Test: unsupported settings fail with their name; a missing core gives an install hint (story 6 scenario 3, FR-026). + - Do: Table test over: region mask, column scaling (`apply_scaling_matrix=True`), `use_regularisation_weight_scale=True`, constant-norm iterations (`constant_norm_iterations`), `interface_pair_mode="all"`, `regularisation_matrix_free=True`. Each with `solver="native"` raises `NotImplementedError` and the message contains the setting name. + - Do: A test that monkeypatches the import of `loop_interpolation_core` to fail: choosing `"native"` raises `ImportError` with the text `pip install loop-interpolation[native]`. This test does not need the core. + - Files: `packages/loop_interpolation/tests/test_native_errors.py` + - Needs: T004 + - Wave: 2 + - Done when: `python -m pytest packages/loop_interpolation/tests/test_native_errors.py -q` collects the tests. +- [x] T030 [P] [US6] [M] Test: with `native` set before setup no sparse rows are built; set after setup it still works with a warning (FR-019, plan D7.2). + - Do: (a) Create the interpolator with `solver="native"`, set constraints, call `setup()`: `interpolator.constraints` is empty (no rows) and the diagnostics report exists. + - Do: (b) Call `setup()` with `solver="cg"`, then `solve_system("native")`: rows exist, the field is correct, and one warning about memory is logged (`caplog`). + - Do: (c) `update()` after (a) does not raise and the field is the same. + - Do: (d) With no `loop_interpolation_core` installed, existing `solver="cg"`, `"lsmr"` and `"admm"` calls give the same results as before (import of `loop_interpolation` never imports the core). + - Files: `packages/loop_interpolation/tests/test_native_no_rows.py` + - Needs: T004 + - Wave: 2 + - Done when: `python -m pytest packages/loop_interpolation/tests/test_native_no_rows.py -q` collects the tests. +- [x] T031 [P] [US6] [S] Test: `resolve_solver_choice("native")` returns `"native"`. + - Do: Add one test: `resolve_solver_choice("native", logger)` returns `"native"` and logs no warning. + - Files: `packages/loop_interpolation/tests/test_solver_strategy.py` + - Needs: none + - Wave: 1 + - Done when: `python -m pytest packages/loop_interpolation/tests/test_solver_strategy.py -q -k native` fails. +- [x] T032 [P] [US6] [S] Accept `"native"` in `resolve_solver_choice`. + - Do: Add `"native"` to the list `["cg", "lsmr", "admm", None]`. + - Files: `packages/loop_interpolation/src/loop_interpolation/_solver_strategy.py` + - Needs: T031 + - Wave: 2 + - Done when: `python -m pytest packages/loop_interpolation/tests/test_solver_strategy.py -q` passes. +- [x] T033 [P] [US6] [S] Add the optional extra `native` to the `loop-interpolation` package. + - Do: Add `native = ["loop-interpolation-core"]` under `[project.optional-dependencies]`. Do not change `dependencies`. + - Files: `packages/loop_interpolation/pyproject.toml` + - Needs: none + - Wave: 1 + - Done when: `python -c "import tomllib; d=tomllib.load(open('packages/loop_interpolation/pyproject.toml','rb')); assert d['project']['optional-dependencies']['native']==['loop-interpolation-core']"` exits 0. +- [x] T034 [P] [US6] [M] Write the PyO3 bindings (feature `python`). + - Do: Read `plan.md` D8 (Python API). Class `Problem` with the methods of D8. Arrays come in as `PyReadonlyArray1` and are checked for contiguity. Settings come as a `dict` with the keys of D8; unknown keys raise `ValueError`. + - Do: `solve` returns `(numpy field, dict report)`. In `F32` mode the field is `float32`. + - Do: `CoreError` becomes `ValueError` (input), `MemoryError` (OutOfMemory, OverBudget), `RuntimeError` (Infeasible, with `worst` in the message), `NotImplementedError` (Unsupported). Keep the text of the error. + - Do: Take the loop-rs `python.rs` only as a guide for PyO3 0.29 syntax. + - Files: `packages/loop_interpolation_core/src/python.rs` + - Needs: T025, T022 + - Wave: 14 + - Done when: `cargo check --manifest-path packages/loop_interpolation_core/Cargo.toml --features python` exits 0. +- [x] T035 [P] [US6] [M] Write the adapter `_native.py`: map the stored constraints to core calls and solve. + - Do: Function `solve(interpolator, tol, solver_kwargs) -> bool` and function `check_supported(interpolator, setup_kwargs, solver_kwargs)`. + - Do: Read the data with `get_value_constraints()`, `get_gradient_constraints()`, `get_norm_constraints()`, `get_tangent_constraints()`, `get_interface_constraints()`, `get_inequality_value_constraints()`, `get_inequality_pairs_constraints()`. Do not read sparse rows. + - Do: Use the weight and gradient mapping that `fixtures/make_fixture.py` documents (cross-derivative weights divided by 4). + - Do: Interface constraints: the core takes one weight. If the weights differ, raise `NotImplementedError` that names `interface per-point weights`. + - Do: `solver_kwargs`: `tol`, `maxiter`, `precision`, `budget_bytes`, `rho`, `inner_max_iter`, `inner_tol`, `admm_abs_tol`, `admm_rel_tol`. Any other key raises `NotImplementedError` with the key name. Do not accept `inequality_method`. + - Do: Return the field as `interpolator.c` (length `n_nodes`), store the report as `interpolator.latest_native_report`, and set `latest_solve_timing` keys `total` (measured with `perf_counter`). + - Do: ImportError text: `pip install loop-interpolation[native]`. + - Files: `packages/loop_interpolation/src/loop_interpolation/_native.py` + - Needs: T027, T028, T029, T034, T005 + - Wave: 15 + - Done when: `python -c "import ast,sys; ast.parse(open('packages/loop_interpolation/src/loop_interpolation/_native.py').read())"` exits 0 and the module imports with no core installed (the core import is inside the function `solve`). +- [x] T036 [P] [US6] [M] Add the `native` branch to `solve_system` and the late-solver warning. + - Do: In `solve_system`, after `_normalise_solver_choice`: if `solver_choice == "native"`, call `_native.check_supported`, then `_native.solve`, set `self.up_to_date`, set `latest_solve_timing` through `_solver_pipeline.finalize_timing`, and return. Do not call `_assemble_main_system`. + - Do: If rows were already built (`self.constraints` is not empty), log one warning: `solver="native" saves memory only when it is set before setup`. + - Files: `packages/loop_interpolation/src/loop_interpolation/_discrete_interpolator.py` + - Needs: T035, T032, T030 + - Wave: 16 + - Done when: `python -m pytest packages/loop_interpolation/tests/test_native_no_rows.py packages/loop_interpolation/tests/test_solver_strategy.py -q` shows no failure (skips allowed until T038). +- [x] T037 [P] [US6] [M] Skip row assembly in `setup_interpolator` when `self.solver == "native"`. + - Do: After the resolve of weights and the regularisation config, if `self.solver == "native"`: store the weights and the setup kwargs (keep `self._setup_kwargs`), skip the `add_*` calls, `assemble_inner`, `add_directional_regularisation` and `assemble_borders`, and return `finalize_setup_diagnostics_report()`. + - Do: Check that `finalize_setup_diagnostics_report` does not need the rows. If it does, change only the report to use the stored data when the solver is native. + - Do: The branch must be safe to run twice (`update()`). + - Files: `packages/loop_interpolation/src/loop_interpolation/_finite_difference_interpolator.py` + - Needs: T030, T032 + - Wave: 3 + - Done when: `python -m pytest packages/loop_interpolation/tests/test_native_no_rows.py -q` shows no failure (skips allowed). +- [x] T038 [US6] [M] Build the wheel into a clean venv and run all Python tests (story 6, FR-017, FR-019). + - Do: Install `maturin` with `uv pip install maturin` in a venv in the scratchpad. Do not put the venv in the repository. + - Do: Fix failures only in the four files of the Files list. If a fix needs another file, stop and report it. + - Files: `packages/loop_interpolation/src/loop_interpolation/_native.py`, `packages/loop_interpolation/src/loop_interpolation/_discrete_interpolator.py`, `packages/loop_interpolation/src/loop_interpolation/_finite_difference_interpolator.py`, `packages/loop_interpolation_core/src/python.rs` + - Needs: T036, T037, T033, T030, T027, T028, T029 + - Wave: 17 + - Done when: In a venv with `maturin develop --release -m packages/loop_interpolation_core/Cargo.toml` done: `python -m pytest packages/loop_interpolation/tests -q` passes with 0 failures and the native tests have 0 skips; with the core NOT installed `python -m pytest packages/loop_interpolation/tests -q` has no failure. + +## Phase 7: User stories 1, 3, 4 - browser build + +Phase C of the plan. Do not start before OP1 is answered (memory per dof from T020). + +- [x] T039 [P] [US1] [M] Test: the Worker solves the 20^3 fixture in Chromium (story 1 scenarios 1 to 4, FR-017). + - Do: Playwright (Chromium only). `harness.html` starts `web/worker.js` as a module Worker and exposes `window.runFixture(bytes)`. The spec fetches `fixtures/fixture_20.bin`, parses it in JavaScript (D8 format), builds the problem through worker messages (D6), and compares the field with `expected_field`: max difference at most 1e-6 of the range. + - Do: The page is served with no cross-origin isolation headers (FR-006). + - Files: `packages/loop_interpolation_core/tests_web/package.json`, `packages/loop_interpolation_core/tests_web/playwright.config.js`, `packages/loop_interpolation_core/tests_web/harness.html`, `packages/loop_interpolation_core/tests_web/fixture.spec.js` + - Needs: T005, T020 + - Wave: 11 + - Done when: `npm --prefix packages/loop_interpolation_core/tests_web test` fails because the package `pkg/` does not exist yet. + - Blocked by: OP1 +- [x] T040 [P] [US3] [M] Test: page responsiveness, cancel time, budget error, second solve refused, memory reading (story 3, story 4, SC-006, FR-010). + - Do: Problem 60^3 generated in the page. (a) A main-thread timer every 10 ms: the largest gap during the solve is below 100 ms. (b) `cancel` after 1 s: the worker answers `cancelled` within 1 s, and a new solve then works. (c) A budget of 1 MB gives an `OverBudget` message with `estimate`, `budget`, `maxNodesPerAxis`. (d) A second `solve` message while one runs gives `SolveRunning` and the first solve ends normally. (e) The worker reports `memoryBytes = WebAssembly.Memory.buffer.byteLength` with the result. (f) Progress messages arrive at least once each second. + - Files: `packages/loop_interpolation_core/tests_web/responsiveness.spec.js` + - Needs: T039 + - Wave: 12 + - Done when: `npm --prefix packages/loop_interpolation_core/tests_web test -- responsiveness` fails (no package). + - Blocked by: OP1 +- [x] T041 [P] [US1] [M] Write the `wasm-bindgen` classes (feature `wasm`). + - Do: Read `plan.md` D6 and D8. Classes `WasmProblem` and `WasmSession` with the JavaScript names of D6 (`Problem`, `Session`) set by `js_name`. + - Do: `CoreError` becomes a thrown `JsError` whose message is JSON text: `{"kind":"OverBudget","estimate":..,"budget":..,"maxNodesPerAxis":..}`. No `serde`: write the JSON by hand. + - Do: `Session.field()` copies out once (`Float64Array` or `Float32Array`). `Session.free()` is the wasm-bindgen free: it drops the session and frees memory. + - Do: Add a `memory_bytes()` function that returns the wasm memory size in bytes. + - Do: `Problem.start` consumes the problem (the Rust `start` takes `self`). After `step` returns done, `WasmSession` keeps the `Field`. `evaluate` and `evaluateGradient` call `Field::evaluate` and `Field::evaluate_gradient` on it. + - Files: `packages/loop_interpolation_core/src/wasm.rs` + - Needs: T025, T022, T040 + - Wave: 14 + - Done when: `cargo check --manifest-path packages/loop_interpolation_core/Cargo.toml --target wasm32-unknown-unknown --features wasm` exits 0. + - Blocked by: OP1 +- [x] T042 [P] [US3] [M] Write the Worker wrapper `web/worker.js` (progress, cancel, one solve at a time). + - Do: Messages in: `init`, `estimate`, `solve`, `cancel`. Messages out: `progress`, `result`, `error`, `cancelled`. + - Do: `solve` builds the problem from transferable buffers (no copy), calls `start`, then loops `step(maxIters)` with `await new Promise(r => setTimeout(r, 0))` between steps. Pick `maxIters` so one step takes about 100 ms (start with 5, scale with the measured time of the last step). + - Do: A `cancel` message sets a flag that the loop checks before each step. On cancel call `session.free()`. + - Do: While a solve runs a second `solve` message answers `error` with kind `SolveRunning`. + - Do: Post `progress` at least once each second with `stage`, `iteration`, `residual`, `maxViolation`. Post `memoryBytes` with `result`. + - Files: `packages/loop_interpolation_core/web/worker.js` + - Needs: T041 + - Wave: 15 + - Done when: `node --check packages/loop_interpolation_core/web/worker.js` exits 0. The browser tests that use it run in the task T045. + - Blocked by: OP1 +- [x] T043 [P] [US1] [S] Write the TypeScript declaration file and the web README. + - Do: Declare the message types of `worker.js` and the classes of D6. README in ASD-STE100: how to load the worker, the message list, and the note that the page must transform coordinates (FR-020). + - Files: `packages/loop_interpolation_core/web/index.d.ts`, `packages/loop_interpolation_core/web/README.md` + - Needs: T042 + - Wave: 16 + - Done when: `npx --prefix packages/loop_interpolation_core/tests_web tsc --noEmit packages/loop_interpolation_core/web/index.d.ts` exits 0. + - Blocked by: OP1 +- [x] T044 [P] [US1] [M] Write the browser build script and the size check (SC-010). + - Do: Steps: `cargo build --profile wasm-size --target wasm32-unknown-unknown --features wasm`; `wasm-bindgen --target web --out-dir pkg`; `wasm-opt -Oz` if present (skip with a message if not). + - Do: `check_wasm_size.py` reads `pkg/*.wasm`, gzips it in memory, prints the size and exits 1 above 2 000 000 bytes. If the size is above, stop and report (OP2). + - Files: `packages/loop_interpolation_core/scripts/build_wasm.sh`, `packages/loop_interpolation_core/scripts/check_wasm_size.py` + - Needs: T041 + - Wave: 15 + - Done when: `bash packages/loop_interpolation_core/scripts/build_wasm.sh` creates `packages/loop_interpolation_core/pkg/` and `python -I packages/loop_interpolation_core/scripts/check_wasm_size.py` prints the gzip size and exits 0 when the size is at most 2 000 000 bytes. + - Blocked by: OP1 +- [x] T045 [US3] [M] Run the browser tests against the built package and fix failures. + - Do: Fix failures only in the two files of the Files list. If a fix needs another file, stop and report it. + - Files: `packages/loop_interpolation_core/src/wasm.rs`, `packages/loop_interpolation_core/web/worker.js` + - Needs: T044, T042, T039, T040, T043 + - Wave: 17 + - Done when: `bash packages/loop_interpolation_core/scripts/build_wasm.sh && npm --prefix packages/loop_interpolation_core/tests_web ci && npx --prefix packages/loop_interpolation_core/tests_web playwright install chromium && npm --prefix packages/loop_interpolation_core/tests_web test` passes. + - Blocked by: OP1 + +## Phase 8: User story 5 - single precision + +Phase E of the plan (was F). It changes the vector type, not the algorithm. + +- [x] T046 [P] [US5] [M] Test: single precision uses at most 60 percent of the memory, agrees with double precision, and reports honestly (story 5, FR-013, FR-022, SC-005). + - Do: (1) 50^3 R1-type problem: peak of `F32` is at most 0.6 times the peak of `F64` (counting allocator). (2) The `F32` field differs from the `F64` field by at most 1e-3 of the range (SC-005). (3) For both modes `report.residual` differs from `Problem::residual_f64(&field)` (a new function that recomputes the residual in `f64`) by at most 1e-6 relative. (4) `F32` with `tol = 1e-12` gives `converged == false` and the report says so. (5) `Field` in `F32` mode holds a `Vec`. + - Files: `packages/loop_interpolation_core/tests/precision_f32.rs` + - Needs: T022, T025 + - Wave: 14 + - Done when: `cargo test --manifest-path packages/loop_interpolation_core/Cargo.toml --test precision_f32` compiles and fails. +- [x] T047 [P] [US5] [M] Add the `Scalar` trait and make `stencil.rs` generic over it. + - Do: `trait Scalar: Copy + Send + Sync + Default + 'static` with `fn from_f64(f64) -> Self`, `fn to_f64(self) -> f64`, and `Add`, `Mul`, `Sub`, `AddAssign` bounds. Implement for `f32` and `f64`. No new dependency. + - Do: Make `Stencil::forward_into`, `adjoint_accumulate_into` and the fused kernel taps generic in the vector type. Taps stay `f64`; the sum runs in `f64`. + - Files: `packages/loop_interpolation_core/src/scalar.rs`, `packages/loop_interpolation_core/src/stencil.rs` + - Needs: T046 + - Wave: 15 + - Done when: `cargo test --manifest-path packages/loop_interpolation_core/Cargo.toml` passes (all `f64` results unchanged). +- [x] T048 [P] [US5] [M] Make the regularisation operator of `interpolator.rs` generic over `Scalar`. + - Do: Generic: `forward_all_families_into`, `adjoint_all_families_accumulate_into`, `fused_interior_regularisation_into`, `shell_regularisation_correction_into`, the border and directional passes. Keep the `f64` wrappers used by the ported tests. + - Files: `packages/loop_interpolation_core/src/interpolator.rs` + - Needs: T047 + - Wave: 16 + - Done when: `cargo test --manifest-path packages/loop_interpolation_core/Cargo.toml` passes (all `f64` results unchanged). +- [x] T049 [P] [US5] [M] Make the data and inequality operator parts generic over `Scalar`. Add `residual_f64`. + - Do: Generic: `accumulate_data_normal`, `data_rhs`, `diagonal`, `apply_normal_operator_into`, the inequality accumulators. Add `pub fn residual_f64(&self, field: &Field) -> f64` that recomputes `|N x - rhs|` in `f64` with a streaming pass. + - Files: `packages/loop_interpolation_core/src/interpolator.rs` + - Needs: T048 + - Wave: 17 + - Done when: `cargo test --manifest-path packages/loop_interpolation_core/Cargo.toml` passes. +- [x] T050 [P] [US5] [M] Make `CgState`, `AdmmState` and the session generic. Select by `Settings.precision`. Dot products add in `f64`. + - Do: `SolveSession` holds an enum `Inner { F64(State), F32(State) }`. `Field` uses `FieldData::F32` in single precision. The bound `F32` mode refuses `NaN` residual and reports `converged == false`. + - Files: `packages/loop_interpolation_core/src/cg.rs`, `packages/loop_interpolation_core/src/admm.rs`, `packages/loop_interpolation_core/src/session.rs`, `packages/loop_interpolation_core/src/field.rs` + - Needs: T049 + - Wave: 18 + - Done when: `cargo test --manifest-path packages/loop_interpolation_core/Cargo.toml --test precision_f32` passes. + +## Phase 9: Finish - measurement, CI, docs, checks + +- [x] T051 [Finish] [M] Add the reference problem tests (SC-001, SC-008, SC-009, SC-012). Record the numbers. + - Do: CI test: for 10 grid sizes from 10^3 to 100^3 the estimate is at least the peak and at most 1.25 times the peak (SC-009). Ignored test extends to 200^3. + - Do: CI test: peak against dof for 50^3, 100^3, 150^3 fits a line with R^2 at least 0.99 (SC-012; 150^3 is `#[ignore]`d, CI uses 30^3, 45^3, 60^3). + - Do: Ignored: R1 peak at most 100 MB (SC-001); 250^3 `F32` with `budget_bytes = 2_000_000_000` peak at most 1.5 GB (SC-008); time of R1 is printed (SC-007, report only). + - Do: Write the field of R1 to `target/r1_field.f64` for the browser comparison. + - Files: `packages/loop_interpolation_core/tests/reference_problems.rs`, `specs/001-low-memory-interpolator/measurements.md` + - Needs: T023, T050, T017 + - Wave: 19 + - Done when: `cargo test --manifest-path packages/loop_interpolation_core/Cargo.toml --release --test reference_problems` passes (CI sizes). `cargo test --manifest-path packages/loop_interpolation_core/Cargo.toml --release --test reference_problems -- --ignored --nocapture` prints the table that is copied to `measurements.md`. +- [x] T052 [P] [Finish] [M] Add the browser reference run: R1 and R2 in Chromium (SC-001, SC-003, SC-006, SC-007). + - Do: Generate the R1 data in JavaScript with the same formulas as `reference_problems.rs` (copy the formulas into both files and name them in a comment). + - Files: `packages/loop_interpolation_core/tests_web/reference.spec.js` + - Needs: T045, T051 + - Wave: 20 + - Done when: `npm --prefix packages/loop_interpolation_core/tests_web test -- reference` prints peak `memoryBytes` (at most 100 000 000 for R1), solve time, and the max difference from `target/r1_field.f64` (at most 1e-6 of the range). The spec is skipped unless the environment variable `RUN_REFERENCE=1` is set. + - Blocked by: OP1 +- [x] T053 [P] [Finish] [M] Write the CI workflow with path filters. + - Do: Trigger on `packages/loop_interpolation_core/**`, `packages/loop_interpolation/src/loop_interpolation/_native.py` and the workflow file. Also `workflow_dispatch`. + - Do: `rust`: `cargo fmt --check`, `cargo clippy -- -D warnings` (no features, `parallel`, `wasm` on target `wasm32-unknown-unknown`, `python`), `cargo test` (with and without `parallel`). + - Do: `wheel`: maturin build, install, run `pytest packages/loop_interpolation/tests` on ubuntu, windows and macos. + - Do: `wasm`: `build_wasm.sh`, `check_wasm_size.py`, Playwright Chromium. + - Do: `reference`: only on `workflow_dispatch`. Runs the ignored tests and the reference spec and writes the numbers to the job summary. + - Files: `.github/workflows/native.yml` + - Needs: T045, T038, T051, T044 + - Wave: 20 + - Done when: `python -c "import yaml; yaml.safe_load(open('.github/workflows/native.yml'))"` exits 0 and the file has jobs `rust`, `wheel`, `wasm`, `reference`. +- [x] T054 [P] [Finish] [S] Write the user guide page for `solver="native"`. + - Do: ASD-STE100 text: what the solver is for, how to install (`pip install loop-interpolation[native]`), how to select it (`solver="native"` before `setup`), the solver options, the unsupported settings list, and the memory estimate. Add `native_solver` to the toctree after `interpolation_options`. + - Files: `docs/source/user_guide/native_solver.rst`, `docs/source/user_guide/index.rst` + - Needs: T038 + - Wave: 18 + - Done when: `grep -q native_solver docs/source/user_guide/index.rst` exits 0 and the file `docs/source/user_guide/native_solver.rst` exists. +- [x] T055 [P] [Finish] [S] Complete the core package README. + - Do: Add: build commands (maturin, wasm), the three forms, the feature flags, the contract (flat arrays, grid coordinates only), and the list of differences from loop-rs (rotation removed, sessions, memory tracking). + - Files: `packages/loop_interpolation_core/README.md` + - Needs: T044, T038 + - Wave: 18 + - Done when: `grep -c "bff51342da8ebff7ca3c87e07c1d4fbd31279c8b" packages/loop_interpolation_core/README.md` prints 1 or more. +- [x] T056 [Finish] [M] Run all checks and fix format and lint problems. + - Do: Fix only format and lint problems. Name the changed files in the report. Do not change behaviour. + - Do: Update the plan and tasks status in the index of `specs/` if an index exists (none exists today). + - Files: none (runs alone; fixes only in files named in the task text) + - Needs: T052, T053, T054, T055, T038, T001, T002, T026 + - Wave: 21 + - Done when: `cargo fmt --manifest-path packages/loop_interpolation_core/Cargo.toml --check`, `cargo clippy --manifest-path packages/loop_interpolation_core/Cargo.toml --all-targets -- -D warnings` (also with `--features parallel`), `cargo test --manifest-path packages/loop_interpolation_core/Cargo.toml`, `cargo test --manifest-path packages/loop_interpolation_core/Cargo.toml --features parallel`, `python -m pytest packages/loop_interpolation/tests -q`, and `python -m ruff check packages/loop_interpolation/src` all exit 0. + +## Dependencies + +- Phase 1 tasks have no dependencies, except that the crate files come before the port. +- The port chain is in one line: copy, remove rotation, rayon shim. The memory harness and the stubs both follow the rayon shim and run at the same time. +- The core API chain is: stubs, then tests for input and the memory tracker, then the code. Sessions need the estimate and the lazy tables. ADMM and the family checks follow the CG session. They edit the same files, so they run one after the other. +- The Python tests need only the package files, so they start early and skip until the wheel is built. The adapter needs the bindings. The two Python edits and the Python check follow. +- The browser tests start after the fixture exists. All browser code waits for OP1. +- Single precision follows ADMM and the family checks and does not need the browser. +- Phase 8 needs all earlier phases. + +## Wave table +| Wave | Tasks | Tier [S] | Tier [M] | +|---|---|---|---| +| 1 | T001, T002, T003, T004, T005, T031, T033 | 6 | 1 | +| 2 | T006, T027, T028, T029, T030, T032 | 2 | 4 | +| 3 | T007, T037 | 0 | 2 | +| 4 | T008 | 0 | 1 | +| 5 | T009, T011 | 0 | 2 | +| 6 | T010, T012, T014 | 1 | 2 | +| 7 | T013, T015 | 1 | 1 | +| 8 | T016, T019 | 0 | 2 | +| 9 | T017, T018 | 0 | 2 | +| 10 | T020 | 0 | 1 | +| 11 | T021, T024, T026, T039 | 0 | 4 | +| 12 | T022, T040 | 0 | 2 | +| 13 | T023, T025 | 0 | 2 | +| 14 | T034, T041, T046 | 0 | 3 | +| 15 | T035, T042, T044, T047 | 0 | 4 | +| 16 | T036, T043, T048 | 1 | 2 | +| 17 | T038, T045, T049 | 0 | 3 | +| 18 | T050, T054, T055 | 2 | 1 | +| 19 | T051 | 0 | 1 | +| 20 | T052, T053 | 0 | 2 | +| 21 | T056 | 0 | 1 | + +## Critical path + +T003 -> T006 -> T007 -> T008 -> T011 -> T012 -> T013 -> T016 -> T017 -> T020 -> T021 -> T022 -> T025 -> T046 -> T047 -> T048 -> T049 -> T050 -> T051 -> T052 -> T056 + +(21 tasks long. It sets the minimum time.) + +## Coverage of the spec + +| Requirement | Tasks | +|---|---| +| FR-001, FR-023 (no matrix) | T010, T020, T051 | +| FR-002, SC-012 (linear memory) | T023, T051 | +| FR-003 (constraint kinds) | T012, T013, T024, T026, T027 | +| FR-004 (inequality kinds) | T021, T022, T028 | +| FR-005 (regularisation) | T026, T027 | +| FR-006 (no isolation, one thread) | T008, T039, T041 | +| FR-007 (thread count) | T024 | +| FR-008, FR-009, FR-010 (responsive, progress, cancel) | T019, T020, T040, T042 | +| FR-011, FR-012 (estimate, budget, no abort) | T014, T015, T016, T017, T018 | +| FR-013, FR-022, SC-005 (precision) | T046, T047, T048, T049, T050 | +| FR-014, FR-015 (report, infeasible) | T019, T021, T022 | +| FR-016 (evaluate) | T024, T025 | +| FR-017 (Python = browser) | T026, T027, T039 | +| FR-018 (bad input) | T012, T013 | +| FR-019 (no change without the solver) | T030, T038 | +| FR-020 (no coordinate change) | T007, T012, T043 | +| FR-021, FR-025 (select solver, same settings) | T032, T036, T037, T035, T033 | +| FR-024 (large origin) | T024 | +| FR-026 (unsupported setting) | T029, T036 | +| SC-001 (100 MB) | T051, T052 | +| SC-002, SC-011 | T023, T051 | +| SC-003 (agreement) | T005, T026, T027, T039 | +| SC-004 (1e-4) | T021, T028 | +| SC-006 (100 ms) | T040, T042 | +| SC-007 (60 s) | T051, T052 (report only; the release check on the reference machine is manual, OP3) | +| SC-008 (250^3 single) | T051 | +| SC-009 (estimate) | T016, T051 | +| SC-010 (2 MB) | T044, T053 | +| Story 1 scenario 3 (Firefox, Safari smoke) | manual, OP3 | diff --git a/uv.lock b/uv.lock index e06e56bc..b1f2a937 100644 --- a/uv.lock +++ b/uv.lock @@ -85,14 +85,14 @@ resolution-markers = [ "python_full_version < '3.10'", ] dependencies = [ - { name = "aiohappyeyeballs", version = "2.6.1", source = { registry = "https://pypi.org/simple" } }, - { name = "aiosignal" }, - { name = "async-timeout" }, - { name = "attrs" }, - { name = "frozenlist" }, - { name = "multidict" }, - { name = "propcache", version = "0.4.1", source = { registry = "https://pypi.org/simple" } }, - { name = "yarl", version = "1.22.0", source = { registry = "https://pypi.org/simple" } }, + { name = "aiohappyeyeballs", version = "2.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "aiosignal", marker = "python_full_version < '3.10'" }, + { name = "async-timeout", marker = "python_full_version < '3.10'" }, + { name = "attrs", marker = "python_full_version < '3.10'" }, + { name = "frozenlist", marker = "python_full_version < '3.10'" }, + { name = "multidict", marker = "python_full_version < '3.10'" }, + { name = "propcache", version = "0.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "yarl", version = "1.22.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" } wheels = [ @@ -238,15 +238,15 @@ resolution-markers = [ "(python_full_version == '3.10.*' and platform_machine != 'ARM64') or (python_full_version == '3.10.*' and sys_platform != 'win32')", ] dependencies = [ - { name = "aiohappyeyeballs", version = "2.7.1", source = { registry = "https://pypi.org/simple" } }, - { name = "aiosignal" }, - { name = "async-timeout", marker = "python_full_version < '3.11'" }, - { name = "attrs" }, - { name = "frozenlist" }, - { name = "multidict" }, - { name = "propcache", version = "0.5.2", source = { registry = "https://pypi.org/simple" } }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, - { name = "yarl", version = "1.24.5", source = { registry = "https://pypi.org/simple" } }, + { name = "aiohappyeyeballs", version = "2.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "aiosignal", marker = "python_full_version >= '3.10'" }, + { name = "async-timeout", marker = "python_full_version == '3.10.*'" }, + { name = "attrs", marker = "python_full_version >= '3.10'" }, + { name = "frozenlist", marker = "python_full_version >= '3.10'" }, + { name = "multidict", marker = "python_full_version >= '3.10'" }, + { name = "propcache", version = "0.5.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.10' and python_full_version < '3.13'" }, + { name = "yarl", version = "1.24.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" } wheels = [ @@ -465,9 +465,9 @@ resolution-markers = [ "python_full_version < '3.10'", ] dependencies = [ - { name = "exceptiongroup" }, - { name = "idna" }, - { name = "typing-extensions" }, + { name = "exceptiongroup", marker = "python_full_version < '3.10'" }, + { name = "idna", marker = "python_full_version < '3.10'" }, + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } wheels = [ @@ -495,9 +495,9 @@ resolution-markers = [ "(python_full_version == '3.10.*' and platform_machine != 'ARM64') or (python_full_version == '3.10.*' and sys_platform != 'win32')", ] dependencies = [ - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, - { name = "idna" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "exceptiongroup", marker = "python_full_version == '3.10.*'" }, + { name = "idna", marker = "python_full_version >= '3.10'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.10' and python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } wheels = [ @@ -681,8 +681,8 @@ resolution-markers = [ "(python_full_version == '3.10.*' and platform_machine != 'ARM64') or (python_full_version == '3.10.*' and sys_platform != 'win32')", ] dependencies = [ - { name = "soupsieve" }, - { name = "typing-extensions" }, + { name = "soupsieve", marker = "python_full_version >= '3.10'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/43/65/318323f98dbee45d42dff61d8f047181bc6f2268a9068cfad035a46be5af/beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7", size = 632571, upload-time = "2026-06-07T16:44:20.453Z" } wheels = [ @@ -697,7 +697,7 @@ resolution-markers = [ "python_full_version < '3.10'", ] dependencies = [ - { name = "webencodings" }, + { name = "webencodings", marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/76/9a/0e33f5054c54d349ea62c277191c020c2d6ef1d65ab2cb1993f91ec846d1/bleach-6.2.0.tar.gz", hash = "sha256:123e894118b8a599fd80d3ec1a6d4cc7ce4e5882b1317a7e1ba69b56e95f991f", size = 203083, upload-time = "2024-10-29T18:30:40.477Z" } wheels = [ @@ -706,7 +706,7 @@ wheels = [ [package.optional-dependencies] css = [ - { name = "tinycss2", version = "1.4.0", source = { registry = "https://pypi.org/simple" } }, + { name = "tinycss2", version = "1.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, ] [[package]] @@ -730,7 +730,7 @@ resolution-markers = [ "(python_full_version == '3.10.*' and platform_machine != 'ARM64') or (python_full_version == '3.10.*' and sys_platform != 'win32')", ] dependencies = [ - { name = "webencodings" }, + { name = "webencodings", marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/48/3c/e12ac860709702bd5ebeb9b56a4fe334f1001246ee1b8f2b7ee28912df7d/bleach-6.4.0.tar.gz", hash = "sha256:4202482733d85cedd04e59fcb2f89f4e4c7c385a78d3c3c23c30446843a37452", size = 204857, upload-time = "2026-06-05T13:01:13.734Z" } wheels = [ @@ -739,7 +739,7 @@ wheels = [ [package.optional-dependencies] css = [ - { name = "tinycss2", version = "1.5.1", source = { registry = "https://pypi.org/simple" } }, + { name = "tinycss2", version = "1.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] [[package]] @@ -759,7 +759,7 @@ resolution-markers = [ "python_full_version < '3.10'", ] dependencies = [ - { name = "pycparser", version = "2.23", source = { registry = "https://pypi.org/simple" }, marker = "implementation_name != 'PyPy'" }, + { name = "pycparser", version = "2.23", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10' and implementation_name != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } wheels = [ @@ -869,7 +869,7 @@ resolution-markers = [ "(python_full_version == '3.10.*' and platform_machine != 'ARM64') or (python_full_version == '3.10.*' and sys_platform != 'win32')", ] dependencies = [ - { name = "pycparser", version = "3.0", source = { registry = "https://pypi.org/simple" }, marker = "implementation_name != 'PyPy'" }, + { name = "pycparser", version = "3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and implementation_name != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" } wheels = [ @@ -982,7 +982,7 @@ resolution-markers = [ "python_full_version < '3.10'", ] dependencies = [ - { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ab/c8/1155d1d58003105307c7e5985f422ae5bcb2ca0cbc553cc828f3c5a934a7/cftime-1.6.4.post1.tar.gz", hash = "sha256:50ac76cc9f10ab7bd46e44a71c51a6927051b499b4407df4f29ab13d741b942f", size = 54631, upload-time = "2024-10-22T18:48:34.194Z" } wheels = [ @@ -1039,7 +1039,7 @@ resolution-markers = [ "(python_full_version == '3.10.*' and platform_machine != 'ARM64') or (python_full_version == '3.10.*' and sys_platform != 'win32')", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/65/dc/470ffebac2eb8c54151eb893055024fe81b1606e7c6ff8449a588e9cd17f/cftime-1.6.5.tar.gz", hash = "sha256:8225fed6b9b43fb87683ebab52130450fc1730011150d3092096a90e54d1e81e", size = 326605, upload-time = "2025-10-13T18:56:26.352Z" } @@ -1285,7 +1285,7 @@ resolution-markers = [ "python_full_version < '3.10'", ] dependencies = [ - { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f5/f6/31a8f28b4a2a4fa0e01085e542f3081ab0588eff8e589d39d775172c9792/contourpy-1.3.0.tar.gz", hash = "sha256:7ffa0db17717a8ffb127efd0c95a4362d996b892c2904db72428d5b52e1938a4", size = 13464370, upload-time = "2024-08-27T21:00:03.328Z" } wheels = [ @@ -1364,7 +1364,7 @@ resolution-markers = [ "(python_full_version == '3.10.*' and platform_machine != 'ARM64') or (python_full_version == '3.10.*' and sys_platform != 'win32')", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/54/eb9bfc647b19f2009dd5c7f5ec51c4e6ca831725f1aea7a993034f483147/contourpy-1.3.2.tar.gz", hash = "sha256:b6945942715a034c671b7fc54f9588126b0b8bf23db2696e3ca8328f3ff0ab54", size = 13466130, upload-time = "2025-04-15T17:47:53.79Z" } wheels = [ @@ -1445,7 +1445,7 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } wheels = [ @@ -1536,12 +1536,12 @@ name = "cyclopts" version = "4.22.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "attrs" }, - { name = "docstring-parser" }, - { name = "rich" }, - { name = "rich-rst" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "attrs", marker = "python_full_version >= '3.10'" }, + { name = "docstring-parser", marker = "python_full_version >= '3.10'" }, + { name = "rich", marker = "python_full_version >= '3.10'" }, + { name = "rich-rst", marker = "python_full_version >= '3.10'" }, + { name = "tomli", marker = "python_full_version == '3.10.*'" }, + { name = "typing-extensions", marker = "python_full_version == '3.10.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/3d/47/32d992e829f63aedea5b93360db23c8882c9bbbde094bcf0fff899ea8a3b/cyclopts-4.22.1.tar.gz", hash = "sha256:49cd3779da7113a96ac5c23b151aa61ac9ae1b4b1fe813594d207ca843c97892", size = 193551, upload-time = "2026-07-20T17:38:59.38Z" } wheels = [ @@ -1637,7 +1637,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -1972,9 +1972,9 @@ resolution-markers = [ "python_full_version < '3.10'", ] dependencies = [ - { name = "h5py", version = "3.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10' or python_full_version >= '3.12'" }, + { name = "h5py", version = "3.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "h5py", version = "3.15.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and python_full_version < '3.12'" }, - { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10' or python_full_version >= '3.12'" }, + { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, ] @@ -1998,11 +1998,11 @@ resolution-markers = [ "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "h5py", version = "3.15.1", source = { registry = "https://pypi.org/simple" } }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, - { name = "pillow", version = "12.2.0", source = { registry = "https://pypi.org/simple" } }, - { name = "psutil" }, - { name = "pydantic" }, + { name = "h5py", version = "3.15.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "pillow", version = "12.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "psutil", marker = "python_full_version >= '3.12'" }, + { name = "pydantic", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ef/24/35166b8975b9e0ef08457001e01b76b3db7870a50f35aa701d8bf993eb8c/geoh5py-0.13.0.tar.gz", hash = "sha256:042df978911187bfd3f0ab4c80da3e13656e79230a22374420c730c1e8106483", size = 200215, upload-time = "2026-07-01T01:36:41.825Z" } wheels = [ @@ -2017,12 +2017,12 @@ resolution-markers = [ "python_full_version < '3.10'", ] dependencies = [ - { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" } }, - { name = "packaging" }, - { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" } }, - { name = "pyogrio", version = "0.11.1", source = { registry = "https://pypi.org/simple" } }, - { name = "pyproj", version = "3.6.1", source = { registry = "https://pypi.org/simple" } }, - { name = "shapely", version = "2.0.7", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "packaging", marker = "python_full_version < '3.10'" }, + { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pyogrio", version = "0.11.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pyproj", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "shapely", version = "2.0.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/39/08/2cf5d85356e45b10b8d066cf4c3ba1e9e3185423c48104eed87e8afd0455/geopandas-1.0.1.tar.gz", hash = "sha256:b8bf70a5534588205b7a56646e2082fb1de9a03599651b3d80c99ea4c2ca08ab", size = 317736, upload-time = "2024-07-02T12:26:52.928Z" } wheels = [ @@ -2050,15 +2050,15 @@ resolution-markers = [ "(python_full_version == '3.10.*' and platform_machine != 'ARM64') or (python_full_version == '3.10.*' and sys_platform != 'win32')", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "packaging" }, - { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "packaging", marker = "python_full_version >= '3.10'" }, + { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, { name = "pandas", version = "3.0.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "pyogrio", version = "0.13.0", source = { registry = "https://pypi.org/simple" } }, - { name = "pyproj", version = "3.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "pyogrio", version = "0.13.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pyproj", version = "3.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, { name = "pyproj", version = "3.7.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "shapely", version = "2.1.2", source = { registry = "https://pypi.org/simple" } }, + { name = "shapely", version = "2.1.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/39/a9/0478b74a33aea66827c10baaf6025cb2a17e44b1c117533eecd3e977bb75/geopandas-1.1.4.tar.gz", hash = "sha256:06f2890a07e1a239047daa14b486a7c6ae5ce82dcf7405e13c46bf31f5d0dd66", size = 337445, upload-time = "2026-06-26T17:23:54.497Z" } wheels = [ @@ -2073,7 +2073,7 @@ resolution-markers = [ "python_full_version < '3.10'", ] dependencies = [ - { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5d/57/dfb3c5c3f1bf5f5ef2e59a22dec4ff1f3d7408b55bfcefcfb0ea69ef21c6/h5py-3.14.0.tar.gz", hash = "sha256:2372116b2e0d5d3e5e705b7f663f7c8d96fa79a4052d250484ef91d24d6a08f4", size = 424323, upload-time = "2025-06-06T14:06:15.01Z" } wheels = [ @@ -2125,7 +2125,7 @@ resolution-markers = [ "(python_full_version == '3.10.*' and platform_machine != 'ARM64') or (python_full_version == '3.10.*' and sys_platform != 'win32')", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/4d/6a/0d79de0b025aa85dc8864de8e97659c94cf3d23148394a954dc5ca52f8c8/h5py-3.15.1.tar.gz", hash = "sha256:c86e3ed45c4473564de55aa83b6fc9e5ead86578773dfbd93047380042e26b69", size = 426236, upload-time = "2025-10-16T10:35:27.404Z" } @@ -2188,8 +2188,8 @@ resolution-markers = [ "python_full_version < '3.10'", ] dependencies = [ - { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" } }, - { name = "pillow", version = "11.3.0", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pillow", version = "11.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a3/6f/606be632e37bf8d05b253e8626c2291d74c691ddc7bcdf7d6aaf33b32f6a/imageio-2.37.2.tar.gz", hash = "sha256:0212ef2727ac9caa5ca4b2c75ae89454312f440a756fcfc8ef1993e718f50f8a", size = 389600, upload-time = "2025-11-04T14:29:39.898Z" } wheels = [ @@ -2217,9 +2217,9 @@ resolution-markers = [ "(python_full_version == '3.10.*' and platform_machine != 'ARM64') or (python_full_version == '3.10.*' and sys_platform != 'win32')", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "pillow", version = "12.2.0", source = { registry = "https://pypi.org/simple" } }, + { name = "pillow", version = "12.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/48/62/aa770a9307508d2a2a2c62d536a49347bffe9e55322db27838d3c93d0b07/imageio-2.37.4.tar.gz", hash = "sha256:e45cbc5e83502047fb138f7f585f7f105a136a57eea5f4b3cfc6ce1b52720bd3", size = 390173, upload-time = "2026-07-20T05:26:11.369Z" } wheels = [ @@ -2268,7 +2268,7 @@ name = "importlib-metadata" version = "8.7.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "zipp" }, + { name = "zipp", marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } wheels = [ @@ -2280,7 +2280,7 @@ name = "importlib-resources" version = "6.5.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "zipp" }, + { name = "zipp", marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cf/8c/f834fbf984f691b4f7ff60f50b514cc3de5cc08abfc3295564dd89c5e2e7/importlib_resources-6.5.2.tar.gz", hash = "sha256:185f87adef5bcc288449d98fb4fba07cea78bc036455dd44c5fc4a2fe78fed2c", size = 44693, upload-time = "2025-01-03T18:51:56.698Z" } wheels = [ @@ -2332,17 +2332,17 @@ resolution-markers = [ "python_full_version < '3.10'", ] dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "decorator" }, - { name = "exceptiongroup" }, - { name = "jedi", version = "0.19.2", source = { registry = "https://pypi.org/simple" } }, - { name = "matplotlib-inline" }, - { name = "pexpect", marker = "sys_platform != 'win32'" }, - { name = "prompt-toolkit" }, - { name = "pygments" }, - { name = "stack-data" }, - { name = "traitlets" }, - { name = "typing-extensions" }, + { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, + { name = "decorator", marker = "python_full_version < '3.10'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.10'" }, + { name = "jedi", version = "0.19.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "matplotlib-inline", marker = "python_full_version < '3.10'" }, + { name = "pexpect", marker = "python_full_version < '3.10' and sys_platform != 'win32'" }, + { name = "prompt-toolkit", marker = "python_full_version < '3.10'" }, + { name = "pygments", marker = "python_full_version < '3.10'" }, + { name = "stack-data", marker = "python_full_version < '3.10'" }, + { name = "traitlets", marker = "python_full_version < '3.10'" }, + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b1/b9/3ba6c45a6df813c09a48bac313c22ff83efa26cbb55011218d925a46e2ad/ipython-8.18.1.tar.gz", hash = "sha256:ca6f079bb33457c66e233e4580ebfc4128855b4cf6370dddd73842a9563e8a27", size = 5486330, upload-time = "2023-11-27T09:58:34.596Z" } wheels = [ @@ -2358,17 +2358,17 @@ resolution-markers = [ "(python_full_version == '3.10.*' and platform_machine != 'ARM64') or (python_full_version == '3.10.*' and sys_platform != 'win32')", ] dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "decorator" }, - { name = "exceptiongroup" }, - { name = "jedi", version = "0.20.0", source = { registry = "https://pypi.org/simple" } }, - { name = "matplotlib-inline" }, - { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "prompt-toolkit" }, - { name = "pygments" }, - { name = "stack-data" }, - { name = "traitlets" }, - { name = "typing-extensions" }, + { name = "colorama", marker = "python_full_version == '3.10.*' and sys_platform == 'win32'" }, + { name = "decorator", marker = "python_full_version == '3.10.*'" }, + { name = "exceptiongroup", marker = "python_full_version == '3.10.*'" }, + { name = "jedi", version = "0.20.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "matplotlib-inline", marker = "python_full_version == '3.10.*'" }, + { name = "pexpect", marker = "python_full_version == '3.10.*' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit", marker = "python_full_version == '3.10.*'" }, + { name = "pygments", marker = "python_full_version == '3.10.*'" }, + { name = "stack-data", marker = "python_full_version == '3.10.*'" }, + { name = "traitlets", marker = "python_full_version == '3.10.*'" }, + { name = "typing-extensions", marker = "python_full_version == '3.10.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/40/18/f8598d287006885e7136451fdea0755af4ebcbfe342836f24deefaed1164/ipython-8.39.0.tar.gz", hash = "sha256:4110ae96012c379b8b6db898a07e186c40a2a1ef5d57a7fa83166047d9da7624", size = 5513971, upload-time = "2026-03-27T10:02:13.94Z" } wheels = [ @@ -2394,18 +2394,18 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "decorator" }, - { name = "ipython-pygments-lexers" }, - { name = "jedi", version = "0.20.0", source = { registry = "https://pypi.org/simple" } }, - { name = "matplotlib-inline" }, - { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "prompt-toolkit" }, - { name = "psutil", marker = "sys_platform != 'cygwin' and sys_platform != 'emscripten'" }, - { name = "pygments" }, - { name = "stack-data" }, - { name = "traitlets" }, - { name = "typing-extensions", marker = "python_full_version < '3.12'" }, + { name = "colorama", marker = "python_full_version >= '3.11' and sys_platform == 'win32'" }, + { name = "decorator", marker = "python_full_version >= '3.11'" }, + { name = "ipython-pygments-lexers", marker = "python_full_version >= '3.11'" }, + { name = "jedi", version = "0.20.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "matplotlib-inline", marker = "python_full_version >= '3.11'" }, + { name = "pexpect", marker = "python_full_version >= '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit", marker = "python_full_version >= '3.11'" }, + { name = "psutil", marker = "python_full_version >= '3.11' and sys_platform != 'cygwin' and sys_platform != 'emscripten'" }, + { name = "pygments", marker = "python_full_version >= '3.11'" }, + { name = "stack-data", marker = "python_full_version >= '3.11'" }, + { name = "traitlets", marker = "python_full_version >= '3.11'" }, + { name = "typing-extensions", marker = "python_full_version == '3.11.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/53/59/165d3b4d75cc34add3122c4417ecb229085140ac573103c223cd01dde96f/ipython-9.15.0.tar.gz", hash = "sha256:da2819ce2aa83135257df830660b1176d986c3d2876db24df01974fa955b2756", size = 4442580, upload-time = "2026-06-26T11:03:35.913Z" } wheels = [ @@ -2417,7 +2417,7 @@ name = "ipython-pygments-lexers" version = "1.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pygments" }, + { name = "pygments", marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } wheels = [ @@ -2462,7 +2462,7 @@ resolution-markers = [ "python_full_version < '3.10'", ] dependencies = [ - { name = "parso" }, + { name = "parso", marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/72/3a/79a912fbd4d8dd6fbb02bf69afd3bb72cf0c729bb3063c6f4498603db17a/jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0", size = 1231287, upload-time = "2024-11-11T01:41:42.873Z" } wheels = [ @@ -2490,7 +2490,7 @@ resolution-markers = [ "(python_full_version == '3.10.*' and platform_machine != 'ARM64') or (python_full_version == '3.10.*' and sys_platform != 'win32')", ] dependencies = [ - { name = "parso" }, + { name = "parso", marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/46/b7/a3635f6a2d7cf5b5dd98064fc1d5fbbafcb25477bcea204a3a92145d158b/jedi-0.20.0.tar.gz", hash = "sha256:c3f4ccbd276696f4b19c54618d4fb18f9fc24b0aef02acf704b23f487daa1011", size = 3119416, upload-time = "2026-05-01T23:38:47.814Z" } wheels = [ @@ -2563,10 +2563,10 @@ resolution-markers = [ "python_full_version < '3.10'", ] dependencies = [ - { name = "attrs" }, - { name = "jsonschema-specifications" }, - { name = "referencing", version = "0.36.2", source = { registry = "https://pypi.org/simple" } }, - { name = "rpds-py", version = "0.27.1", source = { registry = "https://pypi.org/simple" } }, + { name = "attrs", marker = "python_full_version < '3.10'" }, + { name = "jsonschema-specifications", marker = "python_full_version < '3.10'" }, + { name = "referencing", version = "0.36.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "rpds-py", version = "0.27.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/74/69/f7185de793a29082a9f3c7728268ffb31cb5095131a9c139a74078e27336/jsonschema-4.25.1.tar.gz", hash = "sha256:e4a9655ce0da0c0b67a085847e00a3a51449e1157f4f75e9fb5aa545e122eb85", size = 357342, upload-time = "2025-08-18T17:03:50.038Z" } wheels = [ @@ -2575,15 +2575,15 @@ wheels = [ [package.optional-dependencies] format-nongpl = [ - { name = "fqdn" }, - { name = "idna" }, - { name = "isoduration" }, - { name = "jsonpointer", version = "3.0.0", source = { registry = "https://pypi.org/simple" } }, - { name = "rfc3339-validator" }, - { name = "rfc3986-validator" }, - { name = "rfc3987-syntax" }, - { name = "uri-template" }, - { name = "webcolors", version = "24.11.1", source = { registry = "https://pypi.org/simple" } }, + { name = "fqdn", marker = "python_full_version < '3.10'" }, + { name = "idna", marker = "python_full_version < '3.10'" }, + { name = "isoduration", marker = "python_full_version < '3.10'" }, + { name = "jsonpointer", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "rfc3339-validator", marker = "python_full_version < '3.10'" }, + { name = "rfc3986-validator", marker = "python_full_version < '3.10'" }, + { name = "rfc3987-syntax", marker = "python_full_version < '3.10'" }, + { name = "uri-template", marker = "python_full_version < '3.10'" }, + { name = "webcolors", version = "24.11.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, ] [[package]] @@ -2607,10 +2607,10 @@ resolution-markers = [ "(python_full_version == '3.10.*' and platform_machine != 'ARM64') or (python_full_version == '3.10.*' and sys_platform != 'win32')", ] dependencies = [ - { name = "attrs" }, - { name = "jsonschema-specifications" }, - { name = "referencing", version = "0.37.0", source = { registry = "https://pypi.org/simple" } }, - { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "attrs", marker = "python_full_version >= '3.10'" }, + { name = "jsonschema-specifications", marker = "python_full_version >= '3.10'" }, + { name = "referencing", version = "0.37.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, { name = "rpds-py", version = "2026.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } @@ -2620,15 +2620,15 @@ wheels = [ [package.optional-dependencies] format-nongpl = [ - { name = "fqdn" }, - { name = "idna" }, - { name = "isoduration" }, - { name = "jsonpointer", version = "3.1.1", source = { registry = "https://pypi.org/simple" } }, - { name = "rfc3339-validator" }, - { name = "rfc3986-validator" }, - { name = "rfc3987-syntax" }, - { name = "uri-template" }, - { name = "webcolors", version = "25.10.0", source = { registry = "https://pypi.org/simple" } }, + { name = "fqdn", marker = "python_full_version >= '3.10'" }, + { name = "idna", marker = "python_full_version >= '3.10'" }, + { name = "isoduration", marker = "python_full_version >= '3.10'" }, + { name = "jsonpointer", version = "3.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "rfc3339-validator", marker = "python_full_version >= '3.10'" }, + { name = "rfc3986-validator", marker = "python_full_version >= '3.10'" }, + { name = "rfc3987-syntax", marker = "python_full_version >= '3.10'" }, + { name = "uri-template", marker = "python_full_version >= '3.10'" }, + { name = "webcolors", version = "25.10.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] [[package]] @@ -2652,12 +2652,12 @@ resolution-markers = [ "python_full_version < '3.10'", ] dependencies = [ - { name = "importlib-metadata" }, - { name = "jupyter-core", version = "5.8.1", source = { registry = "https://pypi.org/simple" } }, - { name = "python-dateutil" }, - { name = "pyzmq" }, - { name = "tornado" }, - { name = "traitlets" }, + { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, + { name = "jupyter-core", version = "5.8.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "python-dateutil", marker = "python_full_version < '3.10'" }, + { name = "pyzmq", marker = "python_full_version < '3.10'" }, + { name = "tornado", marker = "python_full_version < '3.10'" }, + { name = "traitlets", marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/71/22/bf9f12fdaeae18019a468b68952a60fe6dbab5d67cd2a103cac7659b41ca/jupyter_client-8.6.3.tar.gz", hash = "sha256:35b3a0947c4a6e9d589eb97d7d4cd5e90f910ee73101611f01283732bd6d9419", size = 342019, upload-time = "2024-09-17T10:44:17.613Z" } wheels = [ @@ -2685,12 +2685,12 @@ resolution-markers = [ "(python_full_version == '3.10.*' and platform_machine != 'ARM64') or (python_full_version == '3.10.*' and sys_platform != 'win32')", ] dependencies = [ - { name = "jupyter-core", version = "5.9.1", source = { registry = "https://pypi.org/simple" } }, - { name = "python-dateutil" }, - { name = "pyzmq" }, - { name = "tornado" }, - { name = "traitlets" }, - { name = "typing-extensions" }, + { name = "jupyter-core", version = "5.9.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "python-dateutil", marker = "python_full_version >= '3.10'" }, + { name = "pyzmq", marker = "python_full_version >= '3.10'" }, + { name = "tornado", marker = "python_full_version >= '3.10'" }, + { name = "traitlets", marker = "python_full_version >= '3.10'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7d/dc/5512503b088997c2250b8bf18258fba9d9ce5ead641183700960d3c9d342/jupyter_client-8.9.1.tar.gz", hash = "sha256:a58f730dd9e728ba16ba1d62ebccf7ffe1ebbdbce4e95cfae941b7321ae1f4fa", size = 359256, upload-time = "2026-06-09T13:15:01.033Z" } wheels = [ @@ -2705,9 +2705,9 @@ resolution-markers = [ "python_full_version < '3.10'", ] dependencies = [ - { name = "platformdirs", version = "4.4.0", source = { registry = "https://pypi.org/simple" } }, - { name = "pywin32", marker = "platform_python_implementation != 'PyPy' and sys_platform == 'win32'" }, - { name = "traitlets" }, + { name = "platformdirs", version = "4.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pywin32", marker = "python_full_version < '3.10' and platform_python_implementation != 'PyPy' and sys_platform == 'win32'" }, + { name = "traitlets", marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/99/1b/72906d554acfeb588332eaaa6f61577705e9ec752ddb486f302dafa292d9/jupyter_core-5.8.1.tar.gz", hash = "sha256:0a5f9706f70e64786b75acba995988915ebd4601c8a52e534a40b51c95f59941", size = 88923, upload-time = "2025-05-27T07:38:16.655Z" } wheels = [ @@ -2735,8 +2735,8 @@ resolution-markers = [ "(python_full_version == '3.10.*' and platform_machine != 'ARM64') or (python_full_version == '3.10.*' and sys_platform != 'win32')", ] dependencies = [ - { name = "platformdirs", version = "4.11.0", source = { registry = "https://pypi.org/simple" } }, - { name = "traitlets" }, + { name = "platformdirs", version = "4.11.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "traitlets", marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/02/49/9d1284d0dc65e2c757b74c6687b6d319b02f822ad039e5c512df9194d9dd/jupyter_core-5.9.1.tar.gz", hash = "sha256:4d09aaff303b9566c3ce657f580bd089ff5c91f5f89cf7d8846c3cdf465b5508", size = 89814, upload-time = "2025-10-16T19:19:18.444Z" } wheels = [ @@ -2773,25 +2773,25 @@ resolution-markers = [ "python_full_version < '3.10'", ] dependencies = [ - { name = "anyio", version = "4.12.1", source = { registry = "https://pypi.org/simple" } }, - { name = "argon2-cffi" }, - { name = "jinja2" }, - { name = "jupyter-client", version = "8.6.3", source = { registry = "https://pypi.org/simple" } }, - { name = "jupyter-core", version = "5.8.1", source = { registry = "https://pypi.org/simple" } }, - { name = "jupyter-events" }, - { name = "jupyter-server-terminals" }, - { name = "nbconvert" }, - { name = "nbformat" }, - { name = "overrides" }, - { name = "packaging" }, - { name = "prometheus-client" }, - { name = "pywinpty", version = "3.0.3", source = { registry = "https://pypi.org/simple" }, marker = "os_name == 'nt'" }, - { name = "pyzmq" }, - { name = "send2trash" }, - { name = "terminado" }, - { name = "tornado" }, - { name = "traitlets" }, - { name = "websocket-client" }, + { name = "anyio", version = "4.12.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "argon2-cffi", marker = "python_full_version < '3.10'" }, + { name = "jinja2", marker = "python_full_version < '3.10'" }, + { name = "jupyter-client", version = "8.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "jupyter-core", version = "5.8.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "jupyter-events", marker = "python_full_version < '3.10'" }, + { name = "jupyter-server-terminals", marker = "python_full_version < '3.10'" }, + { name = "nbconvert", marker = "python_full_version < '3.10'" }, + { name = "nbformat", marker = "python_full_version < '3.10'" }, + { name = "overrides", marker = "python_full_version < '3.10'" }, + { name = "packaging", marker = "python_full_version < '3.10'" }, + { name = "prometheus-client", marker = "python_full_version < '3.10'" }, + { name = "pywinpty", version = "3.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10' and os_name == 'nt'" }, + { name = "pyzmq", marker = "python_full_version < '3.10'" }, + { name = "send2trash", marker = "python_full_version < '3.10'" }, + { name = "terminado", marker = "python_full_version < '3.10'" }, + { name = "tornado", marker = "python_full_version < '3.10'" }, + { name = "traitlets", marker = "python_full_version < '3.10'" }, + { name = "websocket-client", marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ca/15/1eacb0fcb79ef86e8a0a79a708e6ad7435f6f223097dd29a4ce861fabc44/jupyter_server-2.18.2.tar.gz", hash = "sha256:06b4f40d8a7a00bb39d5216859c81374a0e7cfefe6d8a5a7facc5a5c37c679a7", size = 753177, upload-time = "2026-05-06T07:04:36.274Z" } wheels = [ @@ -2819,25 +2819,25 @@ resolution-markers = [ "(python_full_version == '3.10.*' and platform_machine != 'ARM64') or (python_full_version == '3.10.*' and sys_platform != 'win32')", ] dependencies = [ - { name = "anyio", version = "4.14.2", source = { registry = "https://pypi.org/simple" } }, - { name = "argon2-cffi" }, - { name = "jinja2" }, - { name = "jupyter-client", version = "8.9.1", source = { registry = "https://pypi.org/simple" } }, - { name = "jupyter-core", version = "5.9.1", source = { registry = "https://pypi.org/simple" } }, - { name = "jupyter-events" }, - { name = "jupyter-server-terminals" }, - { name = "nbconvert" }, - { name = "nbformat" }, - { name = "overrides", marker = "python_full_version < '3.12'" }, - { name = "packaging" }, - { name = "prometheus-client" }, - { name = "pywinpty", version = "3.0.5", source = { registry = "https://pypi.org/simple" }, marker = "os_name == 'nt'" }, - { name = "pyzmq" }, - { name = "send2trash" }, - { name = "terminado" }, - { name = "tornado" }, - { name = "traitlets" }, - { name = "websocket-client" }, + { name = "anyio", version = "4.14.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "argon2-cffi", marker = "python_full_version >= '3.10'" }, + { name = "jinja2", marker = "python_full_version >= '3.10'" }, + { name = "jupyter-client", version = "8.9.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "jupyter-core", version = "5.9.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "jupyter-events", marker = "python_full_version >= '3.10'" }, + { name = "jupyter-server-terminals", marker = "python_full_version >= '3.10'" }, + { name = "nbconvert", marker = "python_full_version >= '3.10'" }, + { name = "nbformat", marker = "python_full_version >= '3.10'" }, + { name = "overrides", marker = "python_full_version >= '3.10' and python_full_version < '3.12'" }, + { name = "packaging", marker = "python_full_version >= '3.10'" }, + { name = "prometheus-client", marker = "python_full_version >= '3.10'" }, + { name = "pywinpty", version = "3.0.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and os_name == 'nt'" }, + { name = "pyzmq", marker = "python_full_version >= '3.10'" }, + { name = "send2trash", marker = "python_full_version >= '3.10'" }, + { name = "terminado", marker = "python_full_version >= '3.10'" }, + { name = "tornado", marker = "python_full_version >= '3.10'" }, + { name = "traitlets", marker = "python_full_version >= '3.10'" }, + { name = "websocket-client", marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/6b/dc/db3a582633170186f8c8b31298d7eb26ad0eb031a1f53476c258b64eed05/jupyter_server-2.20.0.tar.gz", hash = "sha256:b5778ba337d8015a3dc2b80803ecdd5ac18d3797fddf61a50ea5fb472b4ebe14", size = 756523, upload-time = "2026-06-17T12:09:09.435Z" } wheels = [ @@ -3167,7 +3167,7 @@ wheels = [ [[package]] name = "loop-common" -version = "0.0.2" +version = "0.1.0" source = { editable = "packages/loop_common" } dependencies = [ { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, @@ -3205,7 +3205,7 @@ provides-extras = ["tests"] [[package]] name = "loop-interpolation" -version = "0.0.2" +version = "0.1.0" source = { editable = "packages/loop_interpolation" } dependencies = [ { name = "loop-common" }, @@ -3220,6 +3220,9 @@ dependencies = [ ] [package.optional-dependencies] +native = [ + { name = "loop-interpolation-core" }, +] tests = [ { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "pytest", version = "9.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, @@ -3228,12 +3231,26 @@ tests = [ [package.metadata] requires-dist = [ { name = "loop-common", editable = "packages/loop_common" }, + { name = "loop-interpolation-core", marker = "extra == 'native'", directory = "packages/loop_interpolation_core" }, { name = "numpy" }, { name = "pydantic" }, { name = "pytest", marker = "extra == 'tests'" }, { name = "scipy" }, ] -provides-extras = ["tests"] +provides-extras = ["tests", "native"] + +[[package]] +name = "loop-interpolation-core" +version = "0.1.0" +source = { directory = "packages/loop_interpolation_core" } +dependencies = [ + { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] + +[package.metadata] +requires-dist = [{ name = "numpy" }] [[package]] name = "loopprojectfile" @@ -3271,7 +3288,7 @@ wheels = [ [[package]] name = "loopstructural" -version = "1.8.3" +version = "1.9.0" source = { editable = "." } dependencies = [ { name = "loop-common" }, @@ -3406,9 +3423,13 @@ provides-extras = ["all", "visualisation", "export", "jupyter", "inequalities", [[package]] name = "loopstructuralvisualisation" +version = "0.2.2" source = { editable = "packages/loopstructural_visualisation" } dependencies = [ { name = "loopstructural" }, + { name = "matplotlib", version = "3.9.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "matplotlib", version = "3.10.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "matplotlib", version = "3.11.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, @@ -3433,9 +3454,10 @@ tests = [ [package.metadata] requires-dist = [ { name = "loopstructural", editable = "." }, + { name = "matplotlib", specifier = ">=3.6" }, { name = "numpy", specifier = ">=1.18" }, { name = "pytest", marker = "extra == 'tests'" }, - { name = "pyvista", specifier = ">=0.42" }, + { name = "pyvista", specifier = ">=0.44" }, { name = "pyvista", extras = ["all"], marker = "extra == 'all'" }, { name = "pyvista", extras = ["jupyter"], marker = "extra == 'jupyter'" }, ] @@ -3577,7 +3599,7 @@ wheels = [ [[package]] name = "map2loop" -version = "3.5.0" +version = "3.5.1" source = { editable = "packages/map2loop" } dependencies = [ { name = "beartype", version = "0.22.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, @@ -3639,7 +3661,7 @@ resolution-markers = [ "python_full_version < '3.10'", ] dependencies = [ - { name = "mdurl" }, + { name = "mdurl", marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e4/c0/59bd6d0571986f72899288a95d9d6178d0eebd70b6650f1bb3f0da90f8f7/markdown-it-py-2.2.0.tar.gz", hash = "sha256:7c9a5e412688bc771c67432cbfebcdd686c93ce6484913dccf06cb5a0bea35a1", size = 67120, upload-time = "2023-02-22T05:54:30.899Z" } wheels = [ @@ -3655,7 +3677,7 @@ resolution-markers = [ "(python_full_version == '3.10.*' and platform_machine != 'ARM64') or (python_full_version == '3.10.*' and sys_platform != 'win32')", ] dependencies = [ - { name = "mdurl" }, + { name = "mdurl", marker = "python_full_version == '3.10.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } wheels = [ @@ -3681,7 +3703,7 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "mdurl" }, + { name = "mdurl", marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } wheels = [ @@ -3792,16 +3814,16 @@ resolution-markers = [ "python_full_version < '3.10'", ] dependencies = [ - { name = "contourpy", version = "1.3.0", source = { registry = "https://pypi.org/simple" } }, - { name = "cycler" }, - { name = "fonttools", version = "4.60.2", source = { registry = "https://pypi.org/simple" } }, - { name = "importlib-resources" }, - { name = "kiwisolver", version = "1.4.7", source = { registry = "https://pypi.org/simple" } }, - { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" } }, - { name = "packaging" }, - { name = "pillow", version = "11.3.0", source = { registry = "https://pypi.org/simple" } }, - { name = "pyparsing" }, - { name = "python-dateutil" }, + { name = "contourpy", version = "1.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "cycler", marker = "python_full_version < '3.10'" }, + { name = "fonttools", version = "4.60.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "importlib-resources", marker = "python_full_version < '3.10'" }, + { name = "kiwisolver", version = "1.4.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "packaging", marker = "python_full_version < '3.10'" }, + { name = "pillow", version = "11.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pyparsing", marker = "python_full_version < '3.10'" }, + { name = "python-dateutil", marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/df/17/1747b4154034befd0ed33b52538f5eb7752d05bb51c5e2a31470c3bc7d52/matplotlib-3.9.4.tar.gz", hash = "sha256:1e00e8be7393cbdc6fedfa8a6fba02cf3e83814b285db1c60b906a023ba41bc3", size = 36106529, upload-time = "2024-12-13T05:56:34.184Z" } wheels = [ @@ -3856,15 +3878,15 @@ resolution-markers = [ "(python_full_version == '3.10.*' and platform_machine != 'ARM64') or (python_full_version == '3.10.*' and sys_platform != 'win32')", ] dependencies = [ - { name = "contourpy", version = "1.3.2", source = { registry = "https://pypi.org/simple" } }, - { name = "cycler" }, - { name = "fonttools", version = "4.63.0", source = { registry = "https://pypi.org/simple" } }, - { name = "kiwisolver", version = "1.5.0", source = { registry = "https://pypi.org/simple" } }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, - { name = "packaging" }, - { name = "pillow", version = "12.2.0", source = { registry = "https://pypi.org/simple" } }, - { name = "pyparsing" }, - { name = "python-dateutil" }, + { name = "contourpy", version = "1.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "cycler", marker = "python_full_version == '3.10.*'" }, + { name = "fonttools", version = "4.63.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "kiwisolver", version = "1.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "packaging", marker = "python_full_version == '3.10.*'" }, + { name = "pillow", version = "12.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "pyparsing", marker = "python_full_version == '3.10.*'" }, + { name = "python-dateutil", marker = "python_full_version == '3.10.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/63/1b/4be5be87d43d327a0cf4de1a56e86f7f84c89312452406cf122efe2839e6/matplotlib-3.10.9.tar.gz", hash = "sha256:fd66508e8c6877d98e586654b608a0456db8d7e8a546eb1e2600efd957302358", size = 34811233, upload-time = "2026-04-24T00:14:13.539Z" } wheels = [ @@ -3943,15 +3965,15 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "contourpy", version = "1.3.3", source = { registry = "https://pypi.org/simple" } }, - { name = "cycler" }, - { name = "fonttools", version = "4.63.0", source = { registry = "https://pypi.org/simple" } }, - { name = "kiwisolver", version = "1.5.0", source = { registry = "https://pypi.org/simple" } }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, - { name = "packaging" }, - { name = "pillow", version = "12.2.0", source = { registry = "https://pypi.org/simple" } }, - { name = "pyparsing" }, - { name = "python-dateutil" }, + { name = "contourpy", version = "1.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "cycler", marker = "python_full_version >= '3.11'" }, + { name = "fonttools", version = "4.63.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "kiwisolver", version = "1.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "packaging", marker = "python_full_version >= '3.11'" }, + { name = "pillow", version = "12.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pyparsing", marker = "python_full_version >= '3.11'" }, + { name = "python-dateutil", marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/49/64/f9a391af28f518b11ad45a8a712353c94a0aefce09d3703200e5c54b610a/matplotlib-3.11.1.tar.gz", hash = "sha256:69647db5746941c793d6e445a4cd349323ffb87d9cc958c2ad84a659b4832d30", size = 32612045, upload-time = "2026-07-18T03:39:46.63Z" } wheels = [ @@ -4022,7 +4044,7 @@ resolution-markers = [ "python_full_version < '3.10'", ] dependencies = [ - { name = "markdown-it-py", version = "2.2.0", source = { registry = "https://pypi.org/simple" } }, + { name = "markdown-it-py", version = "2.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/49/e7/cc2720da8a32724b36d04c6dba5644154cdf883a1482b3bbb81959a642ed/mdit-py-plugins-0.3.5.tar.gz", hash = "sha256:eee0adc7195e5827e17e02d2a258a2ba159944a0748f59c5099a4a27f78fcf6a", size = 39871, upload-time = "2023-03-02T17:42:50.654Z" } wheels = [ @@ -4050,7 +4072,7 @@ resolution-markers = [ "(python_full_version == '3.10.*' and platform_machine != 'ARM64') or (python_full_version == '3.10.*' and sys_platform != 'win32')", ] dependencies = [ - { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, { name = "markdown-it-py", version = "4.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" } @@ -4456,12 +4478,12 @@ resolution-markers = [ "python_full_version < '3.10'", ] dependencies = [ - { name = "docutils", version = "0.17.1", source = { registry = "https://pypi.org/simple" } }, - { name = "jinja2" }, - { name = "markdown-it-py", version = "2.2.0", source = { registry = "https://pypi.org/simple" } }, - { name = "mdit-py-plugins", version = "0.3.5", source = { registry = "https://pypi.org/simple" } }, - { name = "pyyaml" }, - { name = "sphinx", version = "5.3.0", source = { registry = "https://pypi.org/simple" } }, + { name = "docutils", version = "0.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "jinja2", marker = "python_full_version < '3.10'" }, + { name = "markdown-it-py", version = "2.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "mdit-py-plugins", version = "0.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pyyaml", marker = "python_full_version < '3.10'" }, + { name = "sphinx", version = "5.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5f/69/fbddb50198c6b0901a981e72ae30f1b7769d2dfac88071f7df41c946d133/myst-parser-1.0.0.tar.gz", hash = "sha256:502845659313099542bd38a2ae62f01360e7dd4b1310f025dd014dfc0439cdae", size = 84224, upload-time = "2023-03-07T18:39:13.878Z" } wheels = [ @@ -4477,12 +4499,12 @@ resolution-markers = [ "(python_full_version == '3.10.*' and platform_machine != 'ARM64') or (python_full_version == '3.10.*' and sys_platform != 'win32')", ] dependencies = [ - { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" } }, - { name = "jinja2" }, - { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" } }, - { name = "mdit-py-plugins", version = "0.6.1", source = { registry = "https://pypi.org/simple" } }, - { name = "pyyaml" }, - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" } }, + { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "jinja2", marker = "python_full_version == '3.10.*'" }, + { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "mdit-py-plugins", version = "0.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "pyyaml", marker = "python_full_version == '3.10.*'" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/a5/9626ba4f73555b3735ad86247a8077d4603aa8628537687c839ab08bfe44/myst_parser-4.0.1.tar.gz", hash = "sha256:5cfea715e4f3574138aecbf7d54132296bfd72bb614d31168f48c477a830a7c4", size = 93985, upload-time = "2025-02-12T10:53:03.833Z" } wheels = [ @@ -4508,12 +4530,12 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" } }, - { name = "jinja2" }, - { name = "markdown-it-py", version = "4.2.0", source = { registry = "https://pypi.org/simple" } }, - { name = "mdit-py-plugins", version = "0.6.1", source = { registry = "https://pypi.org/simple" } }, - { name = "pyyaml" }, - { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "jinja2", marker = "python_full_version >= '3.11'" }, + { name = "markdown-it-py", version = "4.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "mdit-py-plugins", version = "0.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pyyaml", marker = "python_full_version >= '3.11'" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/21/dc/603751677fff302f34396e206b610f556a59d7fe58b9a2145f54e96b48e8/myst_parser-5.1.0.tar.gz", hash = "sha256:ab69322dc6719dcc7f296479dbb70181b66df6ed315064f92dbc85c0e1bf2f02", size = 101182, upload-time = "2026-05-13T09:38:19.361Z" } @@ -4538,10 +4560,10 @@ resolution-markers = [ "python_full_version < '3.10'", ] dependencies = [ - { name = "jupyter-client", version = "8.6.3", source = { registry = "https://pypi.org/simple" } }, - { name = "jupyter-core", version = "5.8.1", source = { registry = "https://pypi.org/simple" } }, - { name = "nbformat" }, - { name = "traitlets" }, + { name = "jupyter-client", version = "8.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "jupyter-core", version = "5.8.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "nbformat", marker = "python_full_version < '3.10'" }, + { name = "traitlets", marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/87/66/7ffd18d58eae90d5721f9f39212327695b749e23ad44b3881744eaf4d9e8/nbclient-0.10.2.tar.gz", hash = "sha256:90b7fc6b810630db87a6d0c2250b1f0ab4cf4d3c27a299b0cde78a4ed3fd9193", size = 62424, upload-time = "2024-12-19T10:32:27.164Z" } wheels = [ @@ -4569,10 +4591,10 @@ resolution-markers = [ "(python_full_version == '3.10.*' and platform_machine != 'ARM64') or (python_full_version == '3.10.*' and sys_platform != 'win32')", ] dependencies = [ - { name = "jupyter-client", version = "8.9.1", source = { registry = "https://pypi.org/simple" } }, - { name = "jupyter-core", version = "5.9.1", source = { registry = "https://pypi.org/simple" } }, - { name = "nbformat" }, - { name = "traitlets" }, + { name = "jupyter-client", version = "8.9.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "jupyter-core", version = "5.9.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "nbformat", marker = "python_full_version >= '3.10'" }, + { name = "traitlets", marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/28/a5/b3bae4b590c0cbcada2c63a34f7580024e834a8ba213e949a2f906705787/nbclient-0.11.0.tar.gz", hash = "sha256:04a134a5b087f2c5887f228aca155db50169b8cd9334dee6942c8e927e56081a", size = 62535, upload-time = "2026-06-05T07:52:41.746Z" } wheels = [ @@ -4652,9 +4674,9 @@ resolution-markers = [ "python_full_version < '3.10'", ] dependencies = [ - { name = "certifi" }, - { name = "cftime", version = "1.6.4.post1", source = { registry = "https://pypi.org/simple" } }, - { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" } }, + { name = "certifi", marker = "python_full_version < '3.10'" }, + { name = "cftime", version = "1.6.4.post1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/71/ed/4d27fcfa40ebfdad3d2088a3de7ee48dbff7f35163e815ec1870d2a7398c/netcdf4-1.7.2.tar.gz", hash = "sha256:a4c6375540b19989896136943abb6d44850ff6f1fa7d3f063253b1ad3f8b7fce", size = 835064, upload-time = "2024-10-22T19:01:25.521Z" } wheels = [ @@ -4703,9 +4725,9 @@ resolution-markers = [ "python_full_version == '3.10.*' and platform_machine == 'ARM64' and sys_platform == 'win32'", ] dependencies = [ - { name = "certifi" }, - { name = "cftime", version = "1.6.5", source = { registry = "https://pypi.org/simple" } }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, + { name = "certifi", marker = "python_full_version == '3.10.*' and platform_machine == 'ARM64' and sys_platform == 'win32'" }, + { name = "cftime", version = "1.6.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*' and platform_machine == 'ARM64' and sys_platform == 'win32'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*' and platform_machine == 'ARM64' and sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0e/76/7bc801796dee752c1ce9cd6935564a6ee79d5c9d9ef9192f57b156495a35/netcdf4-1.7.3.tar.gz", hash = "sha256:83f122fc3415e92b1d4904fd6a0898468b5404c09432c34beb6b16c533884673", size = 836095, upload-time = "2025-10-13T18:38:00.76Z" } @@ -4729,9 +4751,9 @@ resolution-markers = [ "(python_full_version == '3.10.*' and platform_machine != 'ARM64') or (python_full_version == '3.10.*' and sys_platform != 'win32')", ] dependencies = [ - { name = "certifi" }, - { name = "cftime", version = "1.6.5", source = { registry = "https://pypi.org/simple" } }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and platform_machine != 'ARM64') or (python_full_version < '3.11' and sys_platform != 'win32')" }, + { name = "certifi", marker = "(python_full_version >= '3.10' and platform_machine != 'ARM64') or (python_full_version >= '3.11' and platform_machine == 'ARM64' and sys_platform == 'win32') or (python_full_version >= '3.10' and sys_platform != 'win32')" }, + { name = "cftime", version = "1.6.5", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.10' and platform_machine != 'ARM64') or (python_full_version >= '3.11' and platform_machine == 'ARM64' and sys_platform == 'win32') or (python_full_version >= '3.10' and sys_platform != 'win32')" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version == '3.10.*' and platform_machine != 'ARM64') or (python_full_version == '3.10.*' and sys_platform != 'win32')" }, { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/34/b6/0370bb3af66a12098da06dc5843f3b349b7c83ccbdf7306e7afa6248b533/netcdf4-1.7.4.tar.gz", hash = "sha256:cdbfdc92d6f4d7192ca8506c9b3d4c1d9892969ff28d8e8e1fc97ca08bf12164", size = 838352, upload-time = "2026-01-05T02:27:38.593Z" } @@ -5038,11 +5060,11 @@ resolution-markers = [ "python_full_version < '3.10'", ] dependencies = [ - { name = "lxml" }, - { name = "python-dateutil" }, - { name = "pytz" }, - { name = "pyyaml" }, - { name = "requests", version = "2.15.1", source = { registry = "https://pypi.org/simple" } }, + { name = "lxml", marker = "python_full_version < '3.10'" }, + { name = "python-dateutil", marker = "python_full_version < '3.10'" }, + { name = "pytz", marker = "python_full_version < '3.10'" }, + { name = "pyyaml", marker = "python_full_version < '3.10'" }, + { name = "requests", version = "2.15.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7a/f6/d739b48f3ae0067996fd3c2e11e066c74cd5158e363dc672529385ccca34/OWSLib-0.31.0.tar.gz", hash = "sha256:2ed6540087445cc57d905138a590b6ae58624ec7661b5c1682ed4e3303bcd150", size = 185317, upload-time = "2024-05-11T12:09:00.053Z" } wheels = [ @@ -5062,10 +5084,10 @@ resolution-markers = [ "(python_full_version == '3.10.*' and platform_machine != 'ARM64') or (python_full_version == '3.10.*' and sys_platform != 'win32')", ] dependencies = [ - { name = "lxml" }, - { name = "python-dateutil" }, - { name = "pyyaml" }, - { name = "requests", version = "2.34.2", source = { registry = "https://pypi.org/simple" } }, + { name = "lxml", marker = "python_full_version >= '3.10' and python_full_version < '3.12'" }, + { name = "python-dateutil", marker = "python_full_version >= '3.10' and python_full_version < '3.12'" }, + { name = "pyyaml", marker = "python_full_version >= '3.10' and python_full_version < '3.12'" }, + { name = "requests", version = "2.34.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and python_full_version < '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/bf/03/5df0b995a3a6eefc24cc2320e3fe830d95e76ee6b0b1c6c9d0f1d4040bbe/owslib-0.35.0.tar.gz", hash = "sha256:0182f377bb30d25b78284bbaf82a12dece97902ed844cee88791ff38665b9b00", size = 194282, upload-time = "2025-10-28T15:13:42.594Z" } wheels = [ @@ -5087,10 +5109,10 @@ resolution-markers = [ "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "lxml" }, - { name = "python-dateutil" }, - { name = "pyyaml" }, - { name = "requests", version = "2.34.2", source = { registry = "https://pypi.org/simple" } }, + { name = "lxml", marker = "python_full_version >= '3.12'" }, + { name = "python-dateutil", marker = "python_full_version >= '3.12'" }, + { name = "pyyaml", marker = "python_full_version >= '3.12'" }, + { name = "requests", version = "2.34.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/10/5d/f2b14a61454bc290c48786c1521f7c9e60996cb0d207b544feb722c82dee/owslib-0.36.0.tar.gz", hash = "sha256:b1ea352ee84f37a1a8ae44122b13cbd3b5b7bf9af0f74d84fe9ed7d9613b9543", size = 196090, upload-time = "2026-06-04T07:05:57.616Z" } wheels = [ @@ -5116,11 +5138,11 @@ resolution-markers = [ "python_full_version < '3.10'", ] dependencies = [ - { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.10.*'" }, + { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, - { name = "python-dateutil" }, - { name = "pytz" }, - { name = "tzdata" }, + { name = "python-dateutil", marker = "python_full_version < '3.11'" }, + { name = "pytz", marker = "python_full_version < '3.11'" }, + { name = "tzdata", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } wheels = [ @@ -5199,9 +5221,9 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, - { name = "python-dateutil" }, - { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "python-dateutil", marker = "python_full_version >= '3.11'" }, + { name = "tzdata", marker = "(python_full_version >= '3.11' and sys_platform == 'emscripten') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/be/4f/5f3422a2afec5ffc46308b79e53291365a93748b498ac2e58bead0197916/pandas-3.0.5.tar.gz", hash = "sha256:dca3734d6ab7c906e6730f0788b0a1dbb9f2467731f9711f77995c8e9d62d712", size = 4658219, upload-time = "2026-07-22T22:19:28.819Z" } wheels = [ @@ -5271,7 +5293,7 @@ name = "pexpect" version = "4.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ptyprocess" }, + { name = "ptyprocess", marker = "(python_full_version < '3.11' and platform_machine != 'ARM64' and sys_platform == 'win32') or (python_full_version < '3.10' and platform_machine == 'ARM64' and sys_platform == 'win32') or (python_full_version < '3.11' and sys_platform == 'emscripten') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } wheels = [ @@ -5562,9 +5584,9 @@ resolution-markers = [ "python_full_version < '3.10'", ] dependencies = [ - { name = "appdirs" }, - { name = "packaging" }, - { name = "requests", version = "2.15.1", source = { registry = "https://pypi.org/simple" } }, + { name = "appdirs", marker = "python_full_version < '3.10'" }, + { name = "packaging", marker = "python_full_version < '3.10'" }, + { name = "requests", version = "2.15.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b3/45/d2eaf68ab809155caef416f7281318b4b7753311ea415c9b066dac2dbf9a/pooch-1.5.2.tar.gz", hash = "sha256:5969b2f1defbdc405df932767e05e0b536e2771c27f1f95d7f260bc99bf13581", size = 61167, upload-time = "2021-10-11T09:04:03.077Z" } wheels = [ @@ -5592,9 +5614,9 @@ resolution-markers = [ "(python_full_version == '3.10.*' and platform_machine != 'ARM64') or (python_full_version == '3.10.*' and sys_platform != 'win32')", ] dependencies = [ - { name = "packaging" }, - { name = "platformdirs", version = "4.11.0", source = { registry = "https://pypi.org/simple" } }, - { name = "requests", version = "2.34.2", source = { registry = "https://pypi.org/simple" } }, + { name = "packaging", marker = "python_full_version >= '3.10'" }, + { name = "platformdirs", version = "4.11.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "requests", version = "2.34.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/83/43/85ef45e8b36c6a48546af7b266592dc32d7f67837a6514d111bced6d7d75/pooch-1.9.0.tar.gz", hash = "sha256:de46729579b9857ffd3e741987a2f6d5e0e03219892c167c6578c0091fb511ed", size = 61788, upload-time = "2026-01-30T19:15:09.649Z" } wheels = [ @@ -6165,14 +6187,14 @@ resolution-markers = [ "python_full_version < '3.10'", ] dependencies = [ - { name = "accessible-pygments" }, - { name = "babel" }, - { name = "beautifulsoup4", version = "4.6.3", source = { registry = "https://pypi.org/simple" } }, - { name = "docutils", version = "0.17.1", source = { registry = "https://pypi.org/simple" } }, - { name = "packaging" }, - { name = "pygments" }, - { name = "sphinx", version = "5.3.0", source = { registry = "https://pypi.org/simple" } }, - { name = "typing-extensions" }, + { name = "accessible-pygments", marker = "python_full_version < '3.10'" }, + { name = "babel", marker = "python_full_version < '3.10'" }, + { name = "beautifulsoup4", version = "4.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "docutils", version = "0.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "packaging", marker = "python_full_version < '3.10'" }, + { name = "pygments", marker = "python_full_version < '3.10'" }, + { name = "sphinx", version = "5.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/67/ea/3ab478cccacc2e8ef69892c42c44ae547bae089f356c4b47caf61730958d/pydata_sphinx_theme-0.15.4.tar.gz", hash = "sha256:7762ec0ac59df3acecf49fd2f889e1b4565dbce8b88b2e29ee06fdd90645a06d", size = 2400673, upload-time = "2024-06-25T19:28:45.041Z" } wheels = [ @@ -6188,13 +6210,13 @@ resolution-markers = [ "(python_full_version == '3.10.*' and platform_machine != 'ARM64') or (python_full_version == '3.10.*' and sys_platform != 'win32')", ] dependencies = [ - { name = "accessible-pygments" }, - { name = "babel" }, - { name = "beautifulsoup4", version = "4.15.0", source = { registry = "https://pypi.org/simple" } }, - { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" } }, - { name = "pygments" }, - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" } }, - { name = "typing-extensions" }, + { name = "accessible-pygments", marker = "python_full_version == '3.10.*'" }, + { name = "babel", marker = "python_full_version == '3.10.*'" }, + { name = "beautifulsoup4", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "pygments", marker = "python_full_version == '3.10.*'" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "typing-extensions", marker = "python_full_version == '3.10.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/39/7e/e3defb93f30557ae825a3b3fbc2c6728301e5e9505a85e00d8503345c42c/pydata_sphinx_theme-0.19.0.tar.gz", hash = "sha256:148ba092bd6937b3321385dc482cc69a29ad2ede36547b4f010ade782bc6a062", size = 5004933, upload-time = "2026-06-15T09:31:02.268Z" } wheels = [ @@ -6220,14 +6242,14 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "accessible-pygments" }, - { name = "babel" }, - { name = "beautifulsoup4", version = "4.15.0", source = { registry = "https://pypi.org/simple" } }, - { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" } }, - { name = "jinja2" }, - { name = "pygments" }, - { name = "requests", version = "2.34.2", source = { registry = "https://pypi.org/simple" } }, - { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "accessible-pygments", marker = "python_full_version >= '3.11'" }, + { name = "babel", marker = "python_full_version >= '3.11'" }, + { name = "beautifulsoup4", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "jinja2", marker = "python_full_version >= '3.11'" }, + { name = "pygments", marker = "python_full_version >= '3.11'" }, + { name = "requests", version = "2.34.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ae/8e/add936feaaa9dade7d5b87c6852566d85e518b38ad32224dea32ef958984/pydata_sphinx_theme-0.20.0.tar.gz", hash = "sha256:0da172d41e19a66de875f4002f7054b385372ec65763852193791e658d50bb4a", size = 5004756, upload-time = "2026-07-09T09:09:14.693Z" } @@ -6266,9 +6288,9 @@ resolution-markers = [ "python_full_version < '3.10'", ] dependencies = [ - { name = "certifi" }, - { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" } }, - { name = "packaging" }, + { name = "certifi", marker = "python_full_version < '3.10'" }, + { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "packaging", marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/bf/1d/ae0340237207664e2da1b77f2cdbcb5f81fd0fc9f3200a48ca993a5e12ef/pyogrio-0.11.1.tar.gz", hash = "sha256:e1441dc9c866f10d8e6ae7ea9249a10c1f57ea921b1f19a5b0977ab91ef8082c", size = 287267, upload-time = "2025-08-02T20:19:20.167Z" } wheels = [ @@ -6325,10 +6347,10 @@ resolution-markers = [ "(python_full_version == '3.10.*' and platform_machine != 'ARM64') or (python_full_version == '3.10.*' and sys_platform != 'win32')", ] dependencies = [ - { name = "certifi" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "certifi", marker = "python_full_version >= '3.10'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "packaging" }, + { name = "packaging", marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/de/3c/d2268615e8b749ba59f278b14a495883562e961fa3ad55a9def222bfbd4a/pyogrio-0.13.0.tar.gz", hash = "sha256:9614f27a1891113f80653e0b76b4233ea1fb3beeb1ac46d118ab22e1670f8f13", size = 313103, upload-time = "2026-06-26T15:30:17.375Z" } wheels = [ @@ -6369,7 +6391,7 @@ resolution-markers = [ "python_full_version < '3.10'", ] dependencies = [ - { name = "certifi" }, + { name = "certifi", marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7d/84/2b39bbf888c753ea48b40d47511548c77aa03445465c35cc4c4e9649b643/pyproj-3.6.1.tar.gz", hash = "sha256:44aa7c704c2b7d8fb3d483bbf75af6cb2350d30a63b144279a09b75fead501bf", size = 225131, upload-time = "2023-09-21T02:07:51.593Z" } wheels = [ @@ -6410,7 +6432,7 @@ resolution-markers = [ "(python_full_version == '3.10.*' and platform_machine != 'ARM64') or (python_full_version == '3.10.*' and sys_platform != 'win32')", ] dependencies = [ - { name = "certifi" }, + { name = "certifi", marker = "python_full_version == '3.10.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/67/10/a8480ea27ea4bbe896c168808854d00f2a9b49f95c0319ddcbba693c8a90/pyproj-3.7.1.tar.gz", hash = "sha256:60d72facd7b6b79853f19744779abcd3f804c4e0d4fa8815469db20c9f640a47", size = 226339, upload-time = "2025-02-16T04:28:46.621Z" } wheels = [ @@ -6467,7 +6489,7 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "certifi" }, + { name = "certifi", marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/04/90/67bd7260b4ea9b8b20b4f58afef6c223ecb3abf368eb4ec5bc2cdef81b49/pyproj-3.7.2.tar.gz", hash = "sha256:39a0cf1ecc7e282d1d30f36594ebd55c9fae1fda8a2622cee5d100430628f88c", size = 226279, upload-time = "2025-08-14T12:05:42.18Z" } wheels = [ @@ -6535,13 +6557,13 @@ resolution-markers = [ "python_full_version < '3.10'", ] dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "exceptiongroup" }, - { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" } }, - { name = "packaging" }, - { name = "pluggy" }, - { name = "pygments" }, - { name = "tomli" }, + { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.10'" }, + { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "packaging", marker = "python_full_version < '3.10'" }, + { name = "pluggy", marker = "python_full_version < '3.10'" }, + { name = "pygments", marker = "python_full_version < '3.10'" }, + { name = "tomli", marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } wheels = [ @@ -6569,13 +6591,13 @@ resolution-markers = [ "(python_full_version == '3.10.*' and platform_machine != 'ARM64') or (python_full_version == '3.10.*' and sys_platform != 'win32')", ] dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, - { name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" } }, - { name = "packaging" }, - { name = "pluggy" }, - { name = "pygments" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version == '3.10.*'" }, + { name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "packaging", marker = "python_full_version >= '3.10'" }, + { name = "pluggy", marker = "python_full_version >= '3.10'" }, + { name = "pygments", marker = "python_full_version >= '3.10'" }, + { name = "tomli", marker = "python_full_version == '3.10.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } wheels = [ @@ -6602,7 +6624,7 @@ resolution-markers = [ "python_full_version < '3.10'", ] dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/29/bf/eca6a3d43db1dae7070f70e160ab20b807627ba953663ba07928cdd3dc58/python_json_logger-4.0.0.tar.gz", hash = "sha256:f58e68eb46e1faed27e0f574a55a0455eecd7b8a5b88b85a784519ba3cff047f", size = 17683, upload-time = "2025-10-06T04:15:18.984Z" } wheels = [ @@ -6651,13 +6673,13 @@ resolution-markers = [ "python_full_version < '3.10'", ] dependencies = [ - { name = "matplotlib", version = "3.9.4", source = { registry = "https://pypi.org/simple" } }, - { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" } }, - { name = "pillow", version = "11.3.0", source = { registry = "https://pypi.org/simple" } }, - { name = "pooch", version = "1.5.2", source = { registry = "https://pypi.org/simple" } }, - { name = "scooby", version = "0.11.0", source = { registry = "https://pypi.org/simple" } }, - { name = "typing-extensions" }, - { name = "vtk", version = "9.5.2", source = { registry = "https://pypi.org/simple" } }, + { name = "matplotlib", version = "3.9.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pillow", version = "11.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pooch", version = "1.5.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "scooby", version = "0.11.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, + { name = "vtk", version = "9.5.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1b/bd/a4e4131b2d4b908a060b43f8346ce863d76a6fecf8a1c2c845602e96c610/pyvista-0.46.5.tar.gz", hash = "sha256:b637bfa32136b95e5e5a6d972871606a68e3c625fc652ff5d9f00390294e99c0", size = 2397682, upload-time = "2026-01-15T00:29:12.727Z" } wheels = [ @@ -6666,29 +6688,29 @@ wheels = [ [package.optional-dependencies] all = [ - { name = "cmcrameri" }, - { name = "cmocean" }, - { name = "colorcet", version = "3.1.0", source = { registry = "https://pypi.org/simple" } }, - { name = "imageio", version = "2.37.2", source = { registry = "https://pypi.org/simple" } }, - { name = "ipywidgets" }, - { name = "jupyter-server-proxy" }, - { name = "meshio" }, - { name = "nest-asyncio" }, - { name = "trame", version = "3.12.0", source = { registry = "https://pypi.org/simple" } }, - { name = "trame-client" }, - { name = "trame-server", version = "3.11.2", source = { registry = "https://pypi.org/simple" } }, - { name = "trame-vtk" }, - { name = "trame-vuetify" }, + { name = "cmcrameri", marker = "python_full_version < '3.10'" }, + { name = "cmocean", marker = "python_full_version < '3.10'" }, + { name = "colorcet", version = "3.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "imageio", version = "2.37.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "ipywidgets", marker = "python_full_version < '3.10'" }, + { name = "jupyter-server-proxy", marker = "python_full_version < '3.10'" }, + { name = "meshio", marker = "python_full_version < '3.10'" }, + { name = "nest-asyncio", marker = "python_full_version < '3.10'" }, + { name = "trame", version = "3.12.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "trame-client", marker = "python_full_version < '3.10'" }, + { name = "trame-server", version = "3.11.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "trame-vtk", marker = "python_full_version < '3.10'" }, + { name = "trame-vuetify", marker = "python_full_version < '3.10'" }, ] jupyter = [ - { name = "ipywidgets" }, - { name = "jupyter-server-proxy" }, - { name = "nest-asyncio" }, - { name = "trame", version = "3.12.0", source = { registry = "https://pypi.org/simple" } }, - { name = "trame-client" }, - { name = "trame-server", version = "3.11.2", source = { registry = "https://pypi.org/simple" } }, - { name = "trame-vtk" }, - { name = "trame-vuetify" }, + { name = "ipywidgets", marker = "python_full_version < '3.10'" }, + { name = "jupyter-server-proxy", marker = "python_full_version < '3.10'" }, + { name = "nest-asyncio", marker = "python_full_version < '3.10'" }, + { name = "trame", version = "3.12.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "trame-client", marker = "python_full_version < '3.10'" }, + { name = "trame-server", version = "3.11.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "trame-vtk", marker = "python_full_version < '3.10'" }, + { name = "trame-vuetify", marker = "python_full_version < '3.10'" }, ] [[package]] @@ -6712,16 +6734,16 @@ resolution-markers = [ "(python_full_version == '3.10.*' and platform_machine != 'ARM64') or (python_full_version == '3.10.*' and sys_platform != 'win32')", ] dependencies = [ - { name = "cyclopts" }, - { name = "matplotlib", version = "3.10.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "cyclopts", marker = "python_full_version >= '3.10'" }, + { name = "matplotlib", version = "3.10.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, { name = "matplotlib", version = "3.11.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "pillow", version = "12.2.0", source = { registry = "https://pypi.org/simple" } }, - { name = "pooch", version = "1.9.0", source = { registry = "https://pypi.org/simple" } }, - { name = "scooby", version = "0.11.2", source = { registry = "https://pypi.org/simple" } }, - { name = "typing-extensions" }, - { name = "vtk", version = "9.6.2", source = { registry = "https://pypi.org/simple" } }, + { name = "pillow", version = "12.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pooch", version = "1.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "scooby", version = "0.11.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.10'" }, + { name = "vtk", version = "9.6.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a6/11/554ae45f79d45039c733d93acb36a433b71e8c63a79bbf2f414b3685de18/pyvista-0.48.4.tar.gz", hash = "sha256:c639dad1bddff5e366d77371f66f783f6e6a0581446810a66439902222d8db07", size = 2581423, upload-time = "2026-05-18T02:25:51.828Z" } wheels = [ @@ -6730,31 +6752,31 @@ wheels = [ [package.optional-dependencies] all = [ - { name = "cmcrameri" }, - { name = "cmocean" }, - { name = "colorcet", version = "3.2.1", source = { registry = "https://pypi.org/simple" } }, - { name = "fsspec" }, - { name = "imageio", version = "2.37.4", source = { registry = "https://pypi.org/simple" } }, - { name = "ipywidgets" }, - { name = "jupyter-server-proxy" }, - { name = "meshio" }, - { name = "nest-asyncio2" }, - { name = "pyvista-zstd" }, - { name = "trame", version = "3.13.2", source = { registry = "https://pypi.org/simple" } }, - { name = "trame-client" }, - { name = "trame-server", version = "3.12.5", source = { registry = "https://pypi.org/simple" } }, - { name = "trame-vtk" }, - { name = "trame-vuetify" }, + { name = "cmcrameri", marker = "python_full_version >= '3.10'" }, + { name = "cmocean", marker = "python_full_version >= '3.10'" }, + { name = "colorcet", version = "3.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "fsspec", marker = "python_full_version >= '3.10'" }, + { name = "imageio", version = "2.37.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "ipywidgets", marker = "python_full_version >= '3.10'" }, + { name = "jupyter-server-proxy", marker = "python_full_version >= '3.10'" }, + { name = "meshio", marker = "python_full_version >= '3.10'" }, + { name = "nest-asyncio2", marker = "python_full_version >= '3.10'" }, + { name = "pyvista-zstd", marker = "python_full_version >= '3.10'" }, + { name = "trame", version = "3.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "trame-client", marker = "python_full_version >= '3.10'" }, + { name = "trame-server", version = "3.12.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "trame-vtk", marker = "python_full_version >= '3.10'" }, + { name = "trame-vuetify", marker = "python_full_version >= '3.10'" }, ] jupyter = [ - { name = "ipywidgets" }, - { name = "jupyter-server-proxy" }, - { name = "nest-asyncio2" }, - { name = "trame", version = "3.13.2", source = { registry = "https://pypi.org/simple" } }, - { name = "trame-client" }, - { name = "trame-server", version = "3.12.5", source = { registry = "https://pypi.org/simple" } }, - { name = "trame-vtk" }, - { name = "trame-vuetify" }, + { name = "ipywidgets", marker = "python_full_version >= '3.10'" }, + { name = "jupyter-server-proxy", marker = "python_full_version >= '3.10'" }, + { name = "nest-asyncio2", marker = "python_full_version >= '3.10'" }, + { name = "trame", version = "3.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "trame-client", marker = "python_full_version >= '3.10'" }, + { name = "trame-server", version = "3.12.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "trame-vtk", marker = "python_full_version >= '3.10'" }, + { name = "trame-vuetify", marker = "python_full_version >= '3.10'" }, ] [[package]] @@ -6762,9 +6784,9 @@ name = "pyvista-zstd" version = "0.2.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyvista", version = "0.48.4", source = { registry = "https://pypi.org/simple" } }, - { name = "tqdm" }, - { name = "zstandard" }, + { name = "pyvista", version = "0.48.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "tqdm", marker = "python_full_version >= '3.10'" }, + { name = "zstandard", marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/80/c9/f151a528662a6be65358a7cb06b41b78ef67cbac7854c97e5ad536d500f9/pyvista_zstd-0.2.4.tar.gz", hash = "sha256:bb096c274c1aa9efc348aed61f6b64b12da59bda2e76b00199378bcceda542e8", size = 1038466, upload-time = "2026-06-10T17:14:31.946Z" } wheels = [ @@ -7033,9 +7055,9 @@ resolution-markers = [ "python_full_version < '3.10'", ] dependencies = [ - { name = "attrs" }, - { name = "rpds-py", version = "0.27.1", source = { registry = "https://pypi.org/simple" } }, - { name = "typing-extensions" }, + { name = "attrs", marker = "python_full_version < '3.10'" }, + { name = "rpds-py", version = "0.27.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/2f/db/98b5c277be99dd18bfd91dd04e1b759cad18d1a338188c936e92f921c7e2/referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa", size = 74744, upload-time = "2025-01-25T08:48:16.138Z" } wheels = [ @@ -7063,10 +7085,10 @@ resolution-markers = [ "(python_full_version == '3.10.*' and platform_machine != 'ARM64') or (python_full_version == '3.10.*' and sys_platform != 'win32')", ] dependencies = [ - { name = "attrs" }, - { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "attrs", marker = "python_full_version >= '3.10'" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, { name = "rpds-py", version = "2026.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.10' and python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } wheels = [ @@ -7106,10 +7128,10 @@ resolution-markers = [ "(python_full_version == '3.10.*' and platform_machine != 'ARM64') or (python_full_version == '3.10.*' and sys_platform != 'win32')", ] dependencies = [ - { name = "certifi" }, - { name = "charset-normalizer" }, - { name = "idna" }, - { name = "urllib3" }, + { name = "certifi", marker = "python_full_version >= '3.10'" }, + { name = "charset-normalizer", marker = "python_full_version >= '3.10'" }, + { name = "idna", marker = "python_full_version >= '3.10'" }, + { name = "urllib3", marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } wheels = [ @@ -7169,8 +7191,8 @@ name = "rich-rst" version = "2.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pygments" }, - { name = "rich" }, + { name = "pygments", marker = "python_full_version >= '3.10'" }, + { name = "rich", marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e2/d6/d0b9fafc73b65767200da027acab1db1bdb1048f4fea5ebf659df01c700e/rich_rst-2.1.0.tar.gz", hash = "sha256:f4d117b49697f338769759fa5cacf5197da4888b347b9fda2e50aef5cd8d93bd", size = 302732, upload-time = "2026-07-05T02:59:44.308Z" } wheels = [ @@ -7647,14 +7669,14 @@ resolution-markers = [ "python_full_version < '3.10'", ] dependencies = [ - { name = "imageio", version = "2.37.2", source = { registry = "https://pypi.org/simple" } }, - { name = "lazy-loader" }, - { name = "networkx", version = "3.2.1", source = { registry = "https://pypi.org/simple" } }, - { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" } }, - { name = "packaging" }, - { name = "pillow", version = "11.3.0", source = { registry = "https://pypi.org/simple" } }, - { name = "scipy", version = "1.13.1", source = { registry = "https://pypi.org/simple" } }, - { name = "tifffile", version = "2024.8.30", source = { registry = "https://pypi.org/simple" } }, + { name = "imageio", version = "2.37.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "lazy-loader", marker = "python_full_version < '3.10'" }, + { name = "networkx", version = "3.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "packaging", marker = "python_full_version < '3.10'" }, + { name = "pillow", version = "11.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "scipy", version = "1.13.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "tifffile", version = "2024.8.30", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5d/c5/bcd66bf5aae5587d3b4b69c74bee30889c46c9778e858942ce93a030e1f3/scikit_image-0.24.0.tar.gz", hash = "sha256:5d16efe95da8edbeb363e0c4157b99becbd650a60b77f6e3af5768b66cf007ab", size = 22693928, upload-time = "2024-06-18T19:05:31.49Z" } wheels = [ @@ -7689,14 +7711,14 @@ resolution-markers = [ "(python_full_version == '3.10.*' and platform_machine != 'ARM64') or (python_full_version == '3.10.*' and sys_platform != 'win32')", ] dependencies = [ - { name = "imageio", version = "2.37.4", source = { registry = "https://pypi.org/simple" } }, - { name = "lazy-loader" }, - { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" } }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, - { name = "packaging" }, - { name = "pillow", version = "12.2.0", source = { registry = "https://pypi.org/simple" } }, - { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" } }, - { name = "tifffile", version = "2025.5.10", source = { registry = "https://pypi.org/simple" } }, + { name = "imageio", version = "2.37.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "lazy-loader", marker = "python_full_version == '3.10.*'" }, + { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "packaging", marker = "python_full_version == '3.10.*'" }, + { name = "pillow", version = "12.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "tifffile", version = "2025.5.10", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c7/a8/3c0f256012b93dd2cb6fda9245e9f4bff7dc0486880b248005f15ea2255e/scikit_image-0.25.2.tar.gz", hash = "sha256:e5a37e6cd4d0c018a7a55b9d601357e3382826d3888c10d0213fc63bff977dde", size = 22693594, upload-time = "2025-02-18T18:05:24.538Z" } wheels = [ @@ -7742,15 +7764,15 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "imageio", version = "2.37.4", source = { registry = "https://pypi.org/simple" } }, - { name = "lazy-loader" }, - { name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" } }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, - { name = "packaging" }, - { name = "pillow", version = "12.2.0", source = { registry = "https://pypi.org/simple" } }, - { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "imageio", version = "2.37.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "lazy-loader", marker = "python_full_version >= '3.11'" }, + { name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "packaging", marker = "python_full_version >= '3.11'" }, + { name = "pillow", version = "12.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "tifffile", version = "2026.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "tifffile", version = "2026.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "tifffile", version = "2026.7.14", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a1/b4/2528bb43c67d48053a7a649a9666432dc307d66ba02e3a6d5c40f46655df/scikit_image-0.26.0.tar.gz", hash = "sha256:f5f970ab04efad85c24714321fcc91613fcb64ef2a892a13167df2f3e59199fa", size = 22729739, upload-time = "2025-12-20T17:12:21.824Z" } @@ -7813,10 +7835,10 @@ resolution-markers = [ "python_full_version < '3.10'", ] dependencies = [ - { name = "joblib" }, - { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" } }, - { name = "scipy", version = "1.13.1", source = { registry = "https://pypi.org/simple" } }, - { name = "threadpoolctl" }, + { name = "joblib", marker = "python_full_version < '3.10'" }, + { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "scipy", version = "1.13.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "threadpoolctl", marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9e/a5/4ae3b3a0755f7b35a280ac90b28817d1f380318973cff14075ab41ef50d9/scikit_learn-1.6.1.tar.gz", hash = "sha256:b4fc2525eca2c69a59260f583c56a7557c6ccdf8deafdba6e060f94c1c59738e", size = 7068312, upload-time = "2025-01-10T08:07:55.348Z" } wheels = [ @@ -7860,10 +7882,10 @@ resolution-markers = [ "(python_full_version == '3.10.*' and platform_machine != 'ARM64') or (python_full_version == '3.10.*' and sys_platform != 'win32')", ] dependencies = [ - { name = "joblib" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, - { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" } }, - { name = "threadpoolctl" }, + { name = "joblib", marker = "python_full_version == '3.10.*'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "threadpoolctl", marker = "python_full_version == '3.10.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/98/c2/a7855e41c9d285dfe86dc50b250978105dce513d6e459ea66a6aeb0e1e0c/scikit_learn-1.7.2.tar.gz", hash = "sha256:20e9e49ecd130598f1ca38a1d85090e1a600147b9c02fa6f15d69cb53d968fda", size = 7193136, upload-time = "2025-09-09T08:21:29.075Z" } wheels = [ @@ -7918,12 +7940,12 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "joblib" }, - { name = "narwhals" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, - { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "joblib", marker = "python_full_version >= '3.11'" }, + { name = "narwhals", marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "threadpoolctl" }, + { name = "threadpoolctl", marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fa/6f/37092bdb25f712817231799fc5674d8e704066a8a70c1d2d40517e18b4ab/scikit_learn-1.9.0.tar.gz", hash = "sha256:8833266989d3a5110178a9fae30783675460724d0e1efb13b14901d2c660c557", size = 7750767, upload-time = "2026-06-02T11:54:32.706Z" } wheels = [ @@ -7967,7 +7989,7 @@ resolution-markers = [ "python_full_version < '3.10'", ] dependencies = [ - { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ae/00/48c2f661e2816ccf2ecd77982f6605b2950afe60f60a52b4cbbc2504aa8f/scipy-1.13.1.tar.gz", hash = "sha256:095a87a0312b08dfd6a6155cbbd310a8c51800fc931b8c0b84003014b874ed3c", size = 57210720, upload-time = "2024-05-23T03:29:26.079Z" } wheels = [ @@ -8006,7 +8028,7 @@ resolution-markers = [ "(python_full_version == '3.10.*' and platform_machine != 'ARM64') or (python_full_version == '3.10.*' and sys_platform != 'win32')", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size = 59419214, upload-time = "2025-05-08T16:13:05.955Z" } wheels = [ @@ -8068,7 +8090,7 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } wheels = [ @@ -8149,7 +8171,7 @@ resolution-markers = [ "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" } wheels = [ @@ -8249,7 +8271,7 @@ resolution-markers = [ "python_full_version < '3.10'", ] dependencies = [ - { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/21/c0/a911d1fd765d07a2b6769ce155219a281bfbe311584ebe97340d75c5bdb1/shapely-2.0.7.tar.gz", hash = "sha256:28fe2997aab9a9dc026dc6a355d04e85841546b2a5d232ed953e3321ab958ee5", size = 283413, upload-time = "2025-01-31T01:10:20.787Z" } wheels = [ @@ -8306,7 +8328,7 @@ resolution-markers = [ "(python_full_version == '3.10.*' and platform_machine != 'ARM64') or (python_full_version == '3.10.*' and sys_platform != 'win32')", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/4d/bc/0989043118a27cccb4e906a46b7565ce36ca7b57f5a18b78f4f1b0f72d9d/shapely-2.1.2.tar.gz", hash = "sha256:2ed4ecb28320a433db18a5bf029986aa8afcfd740745e78847e330d5d94922a9", size = 315489, upload-time = "2025-09-24T13:51:41.432Z" } @@ -8413,23 +8435,23 @@ resolution-markers = [ "python_full_version < '3.10'", ] dependencies = [ - { name = "alabaster", version = "0.7.16", source = { registry = "https://pypi.org/simple" } }, - { name = "babel" }, - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "docutils", version = "0.17.1", source = { registry = "https://pypi.org/simple" } }, - { name = "imagesize", version = "1.5.0", source = { registry = "https://pypi.org/simple" } }, - { name = "importlib-metadata" }, - { name = "jinja2" }, - { name = "packaging" }, - { name = "pygments" }, - { name = "requests", version = "2.15.1", source = { registry = "https://pypi.org/simple" } }, - { name = "snowballstemmer" }, - { name = "sphinxcontrib-applehelp" }, - { name = "sphinxcontrib-devhelp" }, - { name = "sphinxcontrib-htmlhelp" }, - { name = "sphinxcontrib-jsmath" }, - { name = "sphinxcontrib-qthelp" }, - { name = "sphinxcontrib-serializinghtml" }, + { name = "alabaster", version = "0.7.16", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "babel", marker = "python_full_version < '3.10'" }, + { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, + { name = "docutils", version = "0.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "imagesize", version = "1.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, + { name = "jinja2", marker = "python_full_version < '3.10'" }, + { name = "packaging", marker = "python_full_version < '3.10'" }, + { name = "pygments", marker = "python_full_version < '3.10'" }, + { name = "requests", version = "2.15.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "snowballstemmer", marker = "python_full_version < '3.10'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version < '3.10'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version < '3.10'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version < '3.10'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version < '3.10'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version < '3.10'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/af/b2/02a43597980903483fe5eb081ee8e0ba2bb62ea43a70499484343795f3bf/Sphinx-5.3.0.tar.gz", hash = "sha256:51026de0a9ff9fc13c05d74913ad66047e104f56a129ff73e174eb5c3ee794b5", size = 6811365, upload-time = "2022-10-16T09:58:25.963Z" } wheels = [ @@ -8445,23 +8467,23 @@ resolution-markers = [ "(python_full_version == '3.10.*' and platform_machine != 'ARM64') or (python_full_version == '3.10.*' and sys_platform != 'win32')", ] dependencies = [ - { name = "alabaster", version = "1.0.0", source = { registry = "https://pypi.org/simple" } }, - { name = "babel" }, - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" } }, - { name = "imagesize", version = "2.0.0", source = { registry = "https://pypi.org/simple" } }, - { name = "jinja2" }, - { name = "packaging" }, - { name = "pygments" }, - { name = "requests", version = "2.34.2", source = { registry = "https://pypi.org/simple" } }, - { name = "snowballstemmer" }, - { name = "sphinxcontrib-applehelp" }, - { name = "sphinxcontrib-devhelp" }, - { name = "sphinxcontrib-htmlhelp" }, - { name = "sphinxcontrib-jsmath" }, - { name = "sphinxcontrib-qthelp" }, - { name = "sphinxcontrib-serializinghtml" }, - { name = "tomli" }, + { name = "alabaster", version = "1.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "babel", marker = "python_full_version == '3.10.*'" }, + { name = "colorama", marker = "python_full_version == '3.10.*' and sys_platform == 'win32'" }, + { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "imagesize", version = "2.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "jinja2", marker = "python_full_version == '3.10.*'" }, + { name = "packaging", marker = "python_full_version == '3.10.*'" }, + { name = "pygments", marker = "python_full_version == '3.10.*'" }, + { name = "requests", version = "2.34.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "snowballstemmer", marker = "python_full_version == '3.10.*'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version == '3.10.*'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version == '3.10.*'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version == '3.10.*'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version == '3.10.*'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version == '3.10.*'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version == '3.10.*'" }, + { name = "tomli", marker = "python_full_version == '3.10.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/be0b61178fe2cdcb67e2a92fc9ebb488e3c51c4f74a36a7824c0adf23425/sphinx-8.1.3.tar.gz", hash = "sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927", size = 8184611, upload-time = "2024-10-13T20:27:13.93Z" } wheels = [ @@ -8479,23 +8501,23 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "alabaster", version = "1.0.0", source = { registry = "https://pypi.org/simple" } }, - { name = "babel" }, - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" } }, - { name = "imagesize", version = "2.0.0", source = { registry = "https://pypi.org/simple" } }, - { name = "jinja2" }, - { name = "packaging" }, - { name = "pygments" }, - { name = "requests", version = "2.34.2", source = { registry = "https://pypi.org/simple" } }, - { name = "roman-numerals" }, - { name = "snowballstemmer" }, - { name = "sphinxcontrib-applehelp" }, - { name = "sphinxcontrib-devhelp" }, - { name = "sphinxcontrib-htmlhelp" }, - { name = "sphinxcontrib-jsmath" }, - { name = "sphinxcontrib-qthelp" }, - { name = "sphinxcontrib-serializinghtml" }, + { name = "alabaster", version = "1.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "babel", marker = "python_full_version == '3.11.*'" }, + { name = "colorama", marker = "python_full_version == '3.11.*' and sys_platform == 'win32'" }, + { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "imagesize", version = "2.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "jinja2", marker = "python_full_version == '3.11.*'" }, + { name = "packaging", marker = "python_full_version == '3.11.*'" }, + { name = "pygments", marker = "python_full_version == '3.11.*'" }, + { name = "requests", version = "2.34.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "roman-numerals", marker = "python_full_version == '3.11.*'" }, + { name = "snowballstemmer", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version == '3.11.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/50/a8c6ccc36d5eacdfd7913ddccd15a9cee03ecafc5ee2bc40e1f168d85022/sphinx-9.0.4.tar.gz", hash = "sha256:594ef59d042972abbc581d8baa577404abe4e6c3b04ef61bd7fc2acbd51f3fa3", size = 8710502, upload-time = "2025-12-04T07:45:27.343Z" } wheels = [ @@ -8517,23 +8539,23 @@ resolution-markers = [ "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "alabaster", version = "1.0.0", source = { registry = "https://pypi.org/simple" } }, - { name = "babel" }, - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" } }, - { name = "imagesize", version = "2.0.0", source = { registry = "https://pypi.org/simple" } }, - { name = "jinja2" }, - { name = "packaging" }, - { name = "pygments" }, - { name = "requests", version = "2.34.2", source = { registry = "https://pypi.org/simple" } }, - { name = "roman-numerals" }, - { name = "snowballstemmer" }, - { name = "sphinxcontrib-applehelp" }, - { name = "sphinxcontrib-devhelp" }, - { name = "sphinxcontrib-htmlhelp" }, - { name = "sphinxcontrib-jsmath" }, - { name = "sphinxcontrib-qthelp" }, - { name = "sphinxcontrib-serializinghtml" }, + { name = "alabaster", version = "1.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "babel", marker = "python_full_version >= '3.12'" }, + { name = "colorama", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, + { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "imagesize", version = "2.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "jinja2", marker = "python_full_version >= '3.12'" }, + { name = "packaging", marker = "python_full_version >= '3.12'" }, + { name = "pygments", marker = "python_full_version >= '3.12'" }, + { name = "requests", version = "2.34.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "roman-numerals", marker = "python_full_version >= '3.12'" }, + { name = "snowballstemmer", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cd/bd/f08eb0f4eed5c83f1ba2a3bd18f7745a2b1525fad70660a1c00224ec468a/sphinx-9.1.0.tar.gz", hash = "sha256:7741722357dd75f8190766926071fed3bdc211c74dd2d7d4df5404da95930ddb", size = 8718324, upload-time = "2025-12-31T15:09:27.646Z" } wheels = [ @@ -8548,7 +8570,7 @@ resolution-markers = [ "python_full_version < '3.10'", ] dependencies = [ - { name = "sphinx", version = "5.3.0", source = { registry = "https://pypi.org/simple" } }, + { name = "sphinx", version = "5.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ec/2b/fdcfecad13079cc5c620b9ed380dc4b29f02cff69a1189057da0909e25ef/sphinx_design-0.6.0.tar.gz", hash = "sha256:ec8e3c5c59fed4049b3a5a2e209360feab31829346b5f6a0c7c342b894082192", size = 2193531, upload-time = "2024-05-22T23:12:19.628Z" } wheels = [ @@ -8564,7 +8586,7 @@ resolution-markers = [ "(python_full_version == '3.10.*' and platform_machine != 'ARM64') or (python_full_version == '3.10.*' and sys_platform != 'win32')", ] dependencies = [ - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" } }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/2b/69/b34e0cb5336f09c6866d53b4a19d76c227cdec1bbc7ac4de63ca7d58c9c7/sphinx_design-0.6.1.tar.gz", hash = "sha256:b44eea3719386d04d765c1a8257caca2b3e6f8421d7b3a5e742c0fd45f84e632", size = 2193689, upload-time = "2024-08-02T13:48:44.277Z" } wheels = [ @@ -8590,7 +8612,7 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/13/7b/804f311da4663a4aecc6cf7abd83443f3d4ded970826d0c958edc77d4527/sphinx_design-0.7.0.tar.gz", hash = "sha256:d2a3f5b19c24b916adb52f97c5f00efab4009ca337812001109084a740ec9b7a", size = 2203582, upload-time = "2026-01-19T13:12:53.297Z" } @@ -8606,8 +8628,8 @@ resolution-markers = [ "python_full_version < '3.10'", ] dependencies = [ - { name = "pillow", version = "11.3.0", source = { registry = "https://pypi.org/simple" } }, - { name = "sphinx", version = "5.3.0", source = { registry = "https://pypi.org/simple" } }, + { name = "pillow", version = "11.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "sphinx", version = "5.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cd/e5/9ccd6ecd492043123adb465cba504217b9f0a82e2cb5b1d7249c648497c6/sphinx_gallery-0.19.0.tar.gz", hash = "sha256:8400cb5240ad642e28a612fdba0667f725d0505a9be0222d0243de60e8af2eb3", size = 471479, upload-time = "2025-02-13T03:24:50.081Z" } wheels = [ @@ -8635,8 +8657,8 @@ resolution-markers = [ "(python_full_version == '3.10.*' and platform_machine != 'ARM64') or (python_full_version == '3.10.*' and sys_platform != 'win32')", ] dependencies = [ - { name = "pillow", version = "12.2.0", source = { registry = "https://pypi.org/simple" } }, - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "pillow", version = "12.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] @@ -8662,11 +8684,11 @@ resolution-markers = [ "python_full_version < '3.10'", ] dependencies = [ - { name = "docutils", version = "0.17.1", source = { registry = "https://pypi.org/simple" } }, - { name = "importlib-metadata" }, - { name = "pybtex" }, - { name = "pybtex-docutils" }, - { name = "sphinx", version = "5.3.0", source = { registry = "https://pypi.org/simple" } }, + { name = "docutils", version = "0.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, + { name = "pybtex", marker = "python_full_version < '3.10'" }, + { name = "pybtex-docutils", marker = "python_full_version < '3.10'" }, + { name = "sphinx", version = "5.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/de/83/1488c9879f2fa3c2cbd6f666c7a3a42a1fa9e08462bec73281fa6c092cba/sphinxcontrib_bibtex-2.6.5.tar.gz", hash = "sha256:9b3224dd6fece9268ebd8c905dc0a83ff2f6c54148a9235fe70e9d1e9ff149c0", size = 118462, upload-time = "2025-06-27T10:40:14.061Z" } wheels = [ @@ -8694,11 +8716,11 @@ resolution-markers = [ "(python_full_version == '3.10.*' and platform_machine != 'ARM64') or (python_full_version == '3.10.*' and sys_platform != 'win32')", ] dependencies = [ - { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "pybtex" }, - { name = "pybtex-docutils" }, - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "pybtex", marker = "python_full_version >= '3.10'" }, + { name = "pybtex-docutils", marker = "python_full_version >= '3.10'" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] @@ -8798,7 +8820,7 @@ resolution-markers = [ "python_full_version < '3.10'", ] dependencies = [ - { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/54/30/7017e5560154c100cad3a801c02adb48879cd8e8cb862b82696d84187184/tifffile-2024.8.30.tar.gz", hash = "sha256:2c9508fe768962e30f87def61819183fb07692c258cb175b3c114828368485a4", size = 365714, upload-time = "2024-08-31T17:32:43.945Z" } wheels = [ @@ -8814,7 +8836,7 @@ resolution-markers = [ "(python_full_version == '3.10.*' and platform_machine != 'ARM64') or (python_full_version == '3.10.*' and sys_platform != 'win32')", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/44/d0/18fed0fc0916578a4463f775b0fbd9c5fed2392152d039df2fb533bfdd5d/tifffile-2025.5.10.tar.gz", hash = "sha256:018335d34283aa3fd8c263bae5c3c2b661ebc45548fde31504016fcae7bf1103", size = 365290, upload-time = "2025-05-10T19:22:34.386Z" } wheels = [ @@ -8832,7 +8854,7 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c5/cb/2f6d79c7576e22c116352a801f4c3c8ace5957e9aced862012430b62e14f/tifffile-2026.3.3.tar.gz", hash = "sha256:d9a1266bed6f2ee1dd0abde2018a38b4f8b2935cb843df381d70ac4eac5458b7", size = 388745, upload-time = "2026-03-03T19:14:38.134Z" } wheels = [ @@ -8854,7 +8876,7 @@ resolution-markers = [ "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/20/2f/e5fe51c8f782241d86fdf7251594b195f0d6c2fcf9d389079de212599246/tifffile-2026.7.14.tar.gz", hash = "sha256:ce2703e5ef22c868f1528d5f5b4ef75eefb019cf628a1c9ec0d17e0afeca8ef5", size = 437660, upload-time = "2026-07-14T23:41:31.737Z" } wheels = [ @@ -8869,7 +8891,7 @@ resolution-markers = [ "python_full_version < '3.10'", ] dependencies = [ - { name = "webencodings" }, + { name = "webencodings", marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7a/fd/7a5ee21fd08ff70d3d33a5781c255cbe779659bd03278feb98b19ee550f4/tinycss2-1.4.0.tar.gz", hash = "sha256:10c0972f6fc0fbee87c3edb76549357415e94548c1ae10ebccdea16fb404a9b7", size = 87085, upload-time = "2024-10-24T14:58:29.895Z" } wheels = [ @@ -8897,7 +8919,7 @@ resolution-markers = [ "(python_full_version == '3.10.*' and platform_machine != 'ARM64') or (python_full_version == '3.10.*' and sys_platform != 'win32')", ] dependencies = [ - { name = "webencodings" }, + { name = "webencodings", marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a3/ae/2ca4913e5c0f09781d75482874c3a95db9105462a92ddd303c7d285d3df2/tinycss2-1.5.1.tar.gz", hash = "sha256:d339d2b616ba90ccce58da8495a78f46e55d4d25f9fd71dfd526f07e7d53f957", size = 88195, upload-time = "2025-11-23T10:29:10.082Z" } wheels = [ @@ -9004,11 +9026,11 @@ resolution-markers = [ "python_full_version < '3.10'", ] dependencies = [ - { name = "pyyaml" }, - { name = "trame-client" }, - { name = "trame-common" }, - { name = "trame-server", version = "3.11.2", source = { registry = "https://pypi.org/simple" } }, - { name = "wslink", version = "2.5.0", source = { registry = "https://pypi.org/simple" } }, + { name = "pyyaml", marker = "python_full_version < '3.10'" }, + { name = "trame-client", marker = "python_full_version < '3.10'" }, + { name = "trame-common", marker = "python_full_version < '3.10'" }, + { name = "trame-server", version = "3.11.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "wslink", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/35/ac/ebd44ac237841d131314e41e0b1654926b77517b0553d7a7f4227778db07/trame-3.12.0.tar.gz", hash = "sha256:88b861162cb8b025e84e93f17dcfd43a84d02d2c1608c9f6d58e3cd646a50c05", size = 23493, upload-time = "2025-08-18T20:21:40.655Z" } wheels = [ @@ -9036,11 +9058,11 @@ resolution-markers = [ "(python_full_version == '3.10.*' and platform_machine != 'ARM64') or (python_full_version == '3.10.*' and sys_platform != 'win32')", ] dependencies = [ - { name = "pyyaml" }, - { name = "trame-client" }, - { name = "trame-common" }, - { name = "trame-server", version = "3.12.5", source = { registry = "https://pypi.org/simple" } }, - { name = "wslink", version = "2.5.7", source = { registry = "https://pypi.org/simple" } }, + { name = "pyyaml", marker = "python_full_version >= '3.10'" }, + { name = "trame-client", marker = "python_full_version >= '3.10'" }, + { name = "trame-common", marker = "python_full_version >= '3.10'" }, + { name = "trame-server", version = "3.12.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "wslink", version = "2.5.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fc/c7/790a5f28d75ea166fd9ec0bc6545e42683dc59ddd0b15ed374d00fb8e187/trame-3.13.2.tar.gz", hash = "sha256:9868d1c2bce981ae2c66eb6a16d39e2a14f042eedb1047666266c753ecaf3f64", size = 26386, upload-time = "2026-05-15T03:50:50.626Z" } wheels = [ @@ -9076,8 +9098,8 @@ resolution-markers = [ "python_full_version < '3.10'", ] dependencies = [ - { name = "more-itertools", version = "10.8.0", source = { registry = "https://pypi.org/simple" } }, - { name = "wslink", version = "2.5.0", source = { registry = "https://pypi.org/simple" } }, + { name = "more-itertools", version = "10.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "wslink", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/da/80/55cf4a58a39c361e038f5148d1ab24ddb11138b46e9baa5128b6d8005b84/trame_server-3.11.2.tar.gz", hash = "sha256:eaea9baa77a94fec62d198781aa2df29efff293a5bc42cfea833841b528f1191", size = 37681, upload-time = "2026-05-07T17:28:36.372Z" } wheels = [ @@ -9105,9 +9127,9 @@ resolution-markers = [ "(python_full_version == '3.10.*' and platform_machine != 'ARM64') or (python_full_version == '3.10.*' and sys_platform != 'win32')", ] dependencies = [ - { name = "more-itertools", version = "11.1.0", source = { registry = "https://pypi.org/simple" } }, - { name = "trame-common" }, - { name = "wslink", version = "2.5.7", source = { registry = "https://pypi.org/simple" } }, + { name = "more-itertools", version = "11.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "trame-common", marker = "python_full_version >= '3.10'" }, + { name = "wslink", version = "2.5.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/81/02/f3c1741595e4ee65778dd385e2e55c8c5dfedbff180896acdbe4277e3dfc/trame_server-3.12.5.tar.gz", hash = "sha256:e2c5245843806553376b587f9bd392b1223bfb86b55c683b5f8ca62c3be35519", size = 37906, upload-time = "2026-06-10T20:42:32.932Z" } wheels = [ @@ -9194,7 +9216,7 @@ resolution-markers = [ "python_full_version < '3.10'", ] dependencies = [ - { name = "matplotlib", version = "3.9.4", source = { registry = "https://pypi.org/simple" } }, + { name = "matplotlib", version = "3.9.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/c1/71/0cc26142f4f5276944c17a8c839b3e06894617627750686e12dab708ade0/vtk-9.5.2-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:9ca87122352cf3c8748fee73c48930efa46fe1a868149a1f760bc17e8fae27ba", size = 86865294, upload-time = "2025-09-18T00:56:08.013Z" }, @@ -9245,7 +9267,7 @@ resolution-markers = [ "(python_full_version == '3.10.*' and platform_machine != 'ARM64') or (python_full_version == '3.10.*' and sys_platform != 'win32')", ] dependencies = [ - { name = "matplotlib", version = "3.10.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "matplotlib", version = "3.10.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, { name = "matplotlib", version = "3.11.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] wheels = [ @@ -9364,8 +9386,8 @@ resolution-markers = [ "python_full_version < '3.10'", ] dependencies = [ - { name = "aiohttp", version = "3.13.5", source = { registry = "https://pypi.org/simple" } }, - { name = "msgpack", version = "1.1.2", source = { registry = "https://pypi.org/simple" } }, + { name = "aiohttp", version = "3.13.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "msgpack", version = "1.1.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/06/28/7c7cf32d544f464b58f14b8b9da2acfd382729c8c2074abe0dfe671361fc/wslink-2.5.0.tar.gz", hash = "sha256:61f79460affeeeb05284821f5ec5bc927153d587b661d6cfe33cbe260f9cfae3", size = 31996, upload-time = "2025-10-20T15:38:53.161Z" } wheels = [ @@ -9393,8 +9415,8 @@ resolution-markers = [ "(python_full_version == '3.10.*' and platform_machine != 'ARM64') or (python_full_version == '3.10.*' and sys_platform != 'win32')", ] dependencies = [ - { name = "aiohttp", version = "3.14.3", source = { registry = "https://pypi.org/simple" } }, - { name = "msgpack", version = "1.2.1", source = { registry = "https://pypi.org/simple" } }, + { name = "aiohttp", version = "3.14.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "msgpack", version = "1.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/74/12/c23927be89cf75c24d774a8ee5bf83e368d0feb39fb361462f03ae8efd26/wslink-2.5.7.tar.gz", hash = "sha256:ed14c2b4749ea231a105a23334d2e0c7783204b2033f46104972a189ea235818", size = 29958, upload-time = "2026-05-15T02:57:36.299Z" } wheels = [ @@ -9409,9 +9431,9 @@ resolution-markers = [ "python_full_version < '3.10'", ] dependencies = [ - { name = "idna" }, - { name = "multidict" }, - { name = "propcache", version = "0.4.1", source = { registry = "https://pypi.org/simple" } }, + { name = "idna", marker = "python_full_version < '3.10'" }, + { name = "multidict", marker = "python_full_version < '3.10'" }, + { name = "propcache", version = "0.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/57/63/0c6ebca57330cd313f6102b16dd57ffaf3ec4c83403dcb45dbd15c6f3ea1/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71", size = 187169, upload-time = "2025-10-06T14:12:55.963Z" } wheels = [ @@ -9567,9 +9589,9 @@ resolution-markers = [ "(python_full_version == '3.10.*' and platform_machine != 'ARM64') or (python_full_version == '3.10.*' and sys_platform != 'win32')", ] dependencies = [ - { name = "idna" }, - { name = "multidict" }, - { name = "propcache", version = "0.5.2", source = { registry = "https://pypi.org/simple" } }, + { name = "idna", marker = "python_full_version >= '3.10'" }, + { name = "multidict", marker = "python_full_version >= '3.10'" }, + { name = "propcache", version = "0.5.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/31/33/ebe9e3d1f86c7a0b51094c0a146392045ca1631d2664889539dec8088a33/yarl-1.24.5.tar.gz", hash = "sha256:e81b83143bee16329c23db3c1b2d82b29892fcbcb849186d2f6e98a5abe9a57f", size = 228679, upload-time = "2026-07-20T02:07:45.435Z" } wheels = [