Skip to content

Commit b3a40ef

Browse files
authored
Adopt adaptive-triangulation 0.3.1: batched tell_pending, Rust default_loss, CI job (#499)
adaptive-triangulation 0.3.1 fixes the degenerate-simplex divergence noted when the backend was introduced (curvature losses crashed on collinear input) and 0.3.0 added two APIs that move LearnerND's remaining Python hot loops into Rust. This bumps _MIN_RUST_VERSION to (0, 3, 1) and adopts both: - tell_pending asks the triangulation for all simplices containing the pending point in one simplices_containing call (with the known containing simplex as a hint) instead of looping point_in_simplex over every neighbouring simplex; the pure-Python backend keeps the original loop. - LearnerND's default loss_per_simplex is the Rust default_loss when the Rust backend is active (same embedded-simplex-volume computation). Together: 1.10x (2D ring_of_fire, 3000 pts) to 1.42x (3D, 1500 pts) faster end-to-end on top of the existing Rust backend, sampling identical points (asserted by a new test). Also adds the Rust-backend CI job deferred when the backend landed: a nox session running the suite with ADAPTIVE_TRIANGULATION_BACKEND=rust, now that the full suite passes with it (294 passed; previously 7 known curvature-loss failures).
1 parent 5814aa0 commit b3a40ef

6 files changed

Lines changed: 103 additions & 20 deletions

File tree

.github/workflows/nox.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,16 @@ jobs:
2626
run: uv run --group nox nox -e "pytest_min_deps-${{ matrix.python-version }}"
2727
- name: Test with nox with all dependencies
2828
run: uv run --group nox nox -e "pytest_all_deps-${{ matrix.python-version }}"
29+
30+
test-rust-backend:
31+
runs-on: ubuntu-latest
32+
steps:
33+
- uses: actions/checkout@v4
34+
- name: Set up Python
35+
uses: actions/setup-python@v5
36+
with:
37+
python-version: "3.13"
38+
- name: Install uv
39+
uses: astral-sh/setup-uv@v6
40+
- name: Test with nox with the Rust triangulation backend
41+
run: uv run --group nox nox -e pytest_rust_backend

adaptive/learner/learnerND.py

Lines changed: 30 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
circumsphere,
2121
point_in_simplex,
2222
resolve_triangulation_class,
23+
rust_default_loss,
2324
simplex_volume_in_embedding,
2425
)
2526
from adaptive.notebook_integration import ensure_holoviews, ensure_plotly
@@ -333,7 +334,9 @@ def __init__(
333334
):
334335
self._triangulation_class = resolve_triangulation_class(triangulation_backend)
335336
self._vdim = None
336-
self.loss_per_simplex = loss_per_simplex or default_loss
337+
# Prefer the Rust implementation of the default loss when the Rust
338+
# backend is active; it computes the same embedded simplex volume.
339+
self.loss_per_simplex = loss_per_simplex or rust_default_loss or default_loss
337340

338341
if hasattr(self.loss_per_simplex, "nth_neighbors"):
339342
if self.loss_per_simplex.nth_neighbors > 1:
@@ -649,35 +652,46 @@ def tell_pending(self, point, *, simplex=None):
649652
if self.tri is None:
650653
return
651654

655+
for simpl in self._simplices_containing_point(point, simplex):
656+
_, to_add = self._add_pending_point_to_simplex(point, simpl)
657+
if to_add is None:
658+
continue
659+
self._update_subsimplex_losses(simpl, to_add)
660+
661+
def _simplices_containing_point(self, point, simplex=None):
662+
"""All simplices of the triangulation containing `point`, found from
663+
the `simplex` hint when given."""
664+
if hasattr(self.tri, "simplices_containing"):
665+
# Rust backend: one call instead of a point_in_simplex loop.
666+
return self.tri.simplices_containing(point, simplex=simplex)
667+
652668
simplex = tuple(simplex or self.tri.locate_point(point))
653669
if not simplex:
654-
return
655-
# Simplex is None if pending point is outside the triangulation,
656-
# then you do not have subtriangles
670+
return []
671+
# Simplex is empty if the pending point is outside the
672+
# triangulation, then you do not have subtriangles
657673

658-
simplex = tuple(simplex)
659674
simplices = [self.tri.vertex_to_simplices[i] for i in simplex]
660675
neighbors = set.union(*simplices)
661676
# Neighbours also includes the simplex itself
677+
return [s for s in neighbors if self.tri.point_in_simplex(point, s)]
662678

663-
for simpl in neighbors:
664-
_, to_add = self._try_adding_pending_point_to_simplex(point, simpl)
665-
if to_add is None:
666-
continue
667-
self._update_subsimplex_losses(simpl, to_add)
668-
669-
def _try_adding_pending_point_to_simplex(self, point, simplex):
670-
# try to insert it
671-
if not self.tri.point_in_simplex(point, simplex):
672-
return None, None
673-
679+
def _add_pending_point_to_simplex(self, point, simplex):
680+
"""Insert `point` into the subtriangulation of `simplex`, which must
681+
contain the point."""
674682
if simplex not in self._subtriangulations:
675683
vertices = self.tri.get_vertices(simplex)
676684
self._subtriangulations[simplex] = self._triangulation_class(vertices)
677685

678686
self._pending_to_simplex[point] = simplex
679687
return self._subtriangulations[simplex].add_point(point)
680688

689+
def _try_adding_pending_point_to_simplex(self, point, simplex):
690+
# try to insert it
691+
if not self.tri.point_in_simplex(point, simplex):
692+
return None, None
693+
return self._add_pending_point_to_simplex(point, simplex)
694+
681695
def _update_subsimplex_losses(self, simplex, new_subsimplices):
682696
loss = self._losses[simplex]
683697

adaptive/learner/triangulation_backend.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,11 @@
2626

2727
import os
2828

29-
# Minimal version that is a complete drop-in for the learners
30-
# (incl. ``get_opposing_vertices`` and pickle/deepcopy support).
31-
_MIN_RUST_VERSION = (0, 2, 1)
29+
# Minimal version that is a complete drop-in for the learners: includes the
30+
# degenerate-simplex fix for curvature losses, plus the batched
31+
# ``simplices_containing`` query and Rust ``default_loss`` that `LearnerND`
32+
# uses when this backend is active.
33+
_MIN_RUST_VERSION = (0, 3, 1)
3234

3335

3436
def _rust_version() -> tuple[int, ...] | None:
@@ -119,6 +121,12 @@ def resolve_triangulation_class(backend="auto"):
119121
point_in_simplex,
120122
simplex_volume_in_embedding,
121123
)
124+
125+
# The Rust implementation of `adaptive.learner.learnerND.default_loss`,
126+
# which `LearnerND` prefers when no loss is given. Defined here (rather
127+
# than re-exporting the Python one) to avoid a circular import with
128+
# `learnerND`; ``None`` means "use the pure-Python default".
129+
from adaptive_triangulation import default_loss as rust_default_loss
122130
else:
123131
from adaptive.learner.triangulation import (
124132
Triangulation,
@@ -132,6 +140,8 @@ def resolve_triangulation_class(backend="auto"):
132140
simplex_volume_in_embedding,
133141
)
134142

143+
rust_default_loss = None
144+
135145
__all__ = [
136146
"TRIANGULATION_BACKEND",
137147
"Triangulation",
@@ -143,5 +153,6 @@ def resolve_triangulation_class(backend="auto"):
143153
"fast_norm",
144154
"orientation",
145155
"point_in_simplex",
156+
"rust_default_loss",
146157
"simplex_volume_in_embedding",
147158
]

adaptive/tests/unit/test_triangulation_backend.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,3 +122,40 @@ def test_learnernd_uses_rust_backend():
122122
learner.tell(point, learner.function(point))
123123
assert isinstance(learner.tri, adaptive_triangulation.Triangulation)
124124
assert learner.npoints >= 50
125+
126+
127+
def test_rust_default_loss_matches_backend():
128+
if backend.TRIANGULATION_BACKEND == "rust":
129+
import adaptive_triangulation
130+
131+
assert backend.rust_default_loss is adaptive_triangulation.default_loss
132+
else:
133+
assert backend.rust_default_loss is None
134+
135+
136+
def _ring_of_fire(xy):
137+
import numpy as np
138+
139+
x, y = xy
140+
a, d = 0.2, 0.5
141+
return x + np.exp(-((x**2 + y**2 - d**2) ** 2) / a**4)
142+
143+
144+
@pytest.mark.skipif(not rust_is_usable(), reason="needs adaptive-triangulation")
145+
def test_rust_backend_samples_identical_points():
146+
# The batched tell_pending path and the Rust default loss must not change
147+
# which points the learner chooses.
148+
from adaptive import LearnerND
149+
150+
learners = {
151+
which: LearnerND(
152+
_ring_of_fire, bounds=[(-1, 1), (-1, 1)], triangulation_backend=which
153+
)
154+
for which in ("python", "rust")
155+
}
156+
for learner in learners.values():
157+
for _ in range(200):
158+
points, _ = learner.ask(1)
159+
for point in points:
160+
learner.tell(point, learner.function(point))
161+
assert sorted(learners["python"].data) == sorted(learners["rust"].data)

noxfile.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,14 @@ def pytest_all_deps(session: nox.Session) -> None:
2727
session.run("pytest", *xdist)
2828

2929

30+
@nox.session(python="3.13")
31+
def pytest_rust_backend(session: nox.Session) -> None:
32+
"""Run the test suite with the Rust triangulation backend required."""
33+
session.install(".[test,other,rust]")
34+
session.run("coverage", "erase")
35+
session.run("pytest", *xdist, env={"ADAPTIVE_TRIANGULATION_BACKEND": "rust"})
36+
37+
3038
@nox.session(python="3.13")
3139
def pytest_typeguard(session: nox.Session) -> None:
3240
"""Run pytest with typeguard."""

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ dependencies = [
2929

3030
[project.optional-dependencies]
3131
rust = [
32-
"adaptive-triangulation>=0.2.1", # Rust-accelerated triangulation backend
32+
"adaptive-triangulation>=0.3.1", # Rust-accelerated triangulation backend
3333
]
3434
other = [
3535
"dill",

0 commit comments

Comments
 (0)