AdaptiveSimplex is a header-only C++20 library for adaptive integration of discontinuous or localized non-smooth functions on simplex meshes.
It is built for high-performance computing workflows where function or vertex evaluation is expensive, and the important structure lives near an interface. A typical example is a level-set or occupation integral:
Why AdaptiveSimplex?
-
β‘ Low overhead: mesh, cache, and cut-simplex bookkeeping stay near the microsecond scale in the supplied benchmarks, so expensive application evaluations can dominate.
-
π― Adaptive by target: set an error target and refinement budget; the algorithm samples where the current estimator says more information is needed, instead of committing to a fixed tensor grid up front.
-
π Strong vertex reuse: every evaluated vertex is stored in
VertexCache<T>, so shared simplex vertices are reused instead of re-evaluated. -
π Incremental updates: refinement updates the integral through local corrections, evaluating only newly introduced vertices and avoiding full recomputation.
-
π Dimension-general: the adaptive simplex machinery is not tied to 2D or level sets; it works for general simplex integrals in any dimension.
-
π§΅ Parallel vertex evaluation: missing vertices can be evaluated in parallel with OpenMP when available.
-
βοΈ Interface-aware simplex integration: for level-set problems, each cut simplex contributes the clipped volume or barycentric moments implied by the vertex data.
Good fits:
- discontinuous or non-smooth integrands with localized structure
- level-set-defined regions, cut cells, and occupation functions
- Fermi-sea or zero-temperature occupation calculations
- expensive vertex evaluations where reuse matters
- problems with a meaningful local error or interface indicator
Poor fits:
- smooth integrals
For smooth integrands, use standard quadrature methods, sparse grids, or even uniform grids. They are usually simpler, faster, and more accurate for that problem class. The smooth two-peak comparison below is included as a reminder: AdaptiveSimplex is not a smooth-quadrature silver bullet, and tensor grids can be competitive or better when the function is cheap and smooth.
This example integrates the occupied part of a rotated ellipse:
The executable constructs a mesh, a vertex cache, and an integrand built from three domain-specific pieces:
#include "adaptivesimplex/adaptive/adaptive_loop.h"
#include "adaptivesimplex/adaptive/simplex_integrand.h"
#include "adaptivesimplex/cut.h"
#include "adaptivesimplex/core/root_mesh.h"
#include "adaptivesimplex/core/vertex_cache.h"
#include "adaptivesimplex_examples/level_set_function.h"
namespace adaptive = adaptivesimplex::adaptive;
namespace core = adaptivesimplex::core;
namespace examples = adaptivesimplex::examples;
auto function = examples::LevelSetIndicator2D{};
auto geometry = core::root_geometry(2, 2);
auto cache = core::VertexCache<examples::LevelSetVertexValue>{};
auto integrand = adaptive::simplex_integrand(
cache,
// 1. Evaluate one mesh vertex.
[function](std::span<const double> point) {
return examples::LevelSetVertexValue{
.level_value = function.level_value(point[0], point[1]),
};
},
// 2. Compute one simplex contribution from cached vertex data.
[function](
const core::Geometry& geometry,
core::SimplexId simplex_id,
const core::VertexCache<examples::LevelSetVertexValue>& vertex_cache
) {
const auto moments = adaptivesimplex::cut::simplex_moments(
geometry,
simplex_id,
[&](core::VertexId vertex_id) {
return vertex_cache.get(vertex_id).level_value;
},
adaptivesimplex::cut::LevelOptions{.level = function.level()}
);
return moments.volume;
}
);
auto options = adaptive::Options{
.target_error = 5e-4,
.max_refinements = 2048,
.preview_depth = 2,
.min_refinement_batch_size = 1,
.max_refinement_batch_size = 4,
};
const auto result = adaptive::run(geometry, integrand, options);For the same example with the helper from examples/common, the executable is
shorter:
auto function = examples::LevelSetIndicator2D{};
auto geometry = examples::level_set_geometry();
auto cache = core::VertexCache<examples::LevelSetVertexValue>{};
auto integrand = examples::make_level_set_integrand(cache, function);
const auto result =
adaptive::run(geometry, integrand, examples::level_set_options());
std::cout << result.integral << "\n";
std::cout << result.stopping_error << "\n";The important data flow is still explicit:
adaptive::run(...)asks the mesh which vertices are missing.- The vertex evaluator computes data for each missing vertex.
VertexCache<T>stores that data byVertexId.- The simplex rule computes coarse and preview contributions.
- Estimation policies turn local correction into global stopping error and local refinement score.
- The adaptive loop refines high-score simplices and updates totals incrementally.
For scalar integrals, the default policies are:
adaptive::estimation_policies{
adaptive::signed_correction_error{},
adaptive::absolute_local_correction{},
};signed_correction_error maintains sum(local.correction) incrementally and
uses std::abs(sum) as the stopping error. Use
adaptive::unsigned_correction_error{} when you want
sum(std::abs(local.correction)) with no cancellation.
Set preview_depth to zero to evaluate only the active mesh, without
speculative child simplices or preview-only vertices. In this mode,
simplex_integrand(...) uses the coarse contribution as the preview and
produces a shape-compatible zero correction. Refinement still bisects selected
simplices by one level, and the returned integral represents the active mesh.
Depth zero must be paired with stopping-error and refinement-score policies
that use an intrinsic simplex estimate; the built-in correction-only policies
otherwise report zero error immediately.
See examples/scalar_integral.cpp for the minimal scalar factory example and examples/level_set_integral.cpp for the runnable level-set example.
simplex_integrand(...) builds the ordinary integrand protocol for you. If you
need full control, a hand-written integrand supplies:
using vertex_value_type = ...using integral_value_type = ...cache()stopping_global_error()evaluate_vertex(geometry, vertex_id)estimate_simplices(geometry, simplex_ids, preview_depth)
estimate_simplices(...) returns one adaptive::SimplexEstimate<T> per
simplex:
adaptive::SimplexEstimate<T>{
.coarse = coarse,
.preview = preview,
.correction = preview - coarse,
.refinement_score = score,
};adaptive::IntegrationResult<T> contains the final accumulated values:
integral: current integral estimatecorrection: accumulated signed correction statestopping_error: scalar stopping criterionevaluations: number of vertex evaluationsrefinements: number of selected parent refinementsconverged: whether the target was reached
Adaptive refinement decides where to refine. Cut-simplex moments decide what a simplex contributes when a discontinuity crosses it.
For a level-set integral, simply averaging vertex indicator values is a poor local quadrature rule: the function jumps across an interface. Instead, AdaptiveSimplex linearly interpolates the level value on each simplex (T):
clips the simplex to the occupied region
and computes barycentric moments
For an indicator-only volume calculation, the moment routine already returns
the occupied simplex volume. That is why the simplex rule above returns
moments.volume.
This is the main reason adaptivesimplex::cut exists: it turns a
level-set-defined discontinuity into a deterministic simplex contribution that
can be reused by the adaptive algorithm.
-
core::GeometryOwns the dimension, dyadic vertices, active simplices, refinement, and simplex volumes. Create a basic domain with
core::root_geometry(dimension, depth). -
core::VertexCache<T>Stores user-defined vertex data keyed by
VertexId. Missing vertices are evaluated once; reused vertices are not counted again as evaluations. -
adaptive::simplex_integrand(...)Factory that connects a cache, vertex evaluator, simplex rule, and estimation policies to the adaptive loop.
-
Vertex evaluator
Computes the cache value for one vertex. In a physics code, this is where an expensive Hamiltonian diagonalization or band calculation would usually live.
-
Simplex rule
Computes the domain-specific integral contribution for one simplex. For level-set integrals this usually calls
cut::simplex_moments(...). -
adaptive::estimation_policiesSelects the global stopping-error accumulator and local refinement-score policy.
-
adaptive::SimplexEstimate<T>Contains
coarse,preview,correction, andrefinement_scorefor one active simplex. -
adaptive::IntegrationResult<T>Contains
integral,correction,stopping_error,evaluations,refinements, andconvergedfor the full adaptive run. -
adaptive::OptionsControls stopping and refinement behavior: target error, maximum refinements, preview depth, and refinement batch size.
-
adaptive::run(...)Evaluates missing vertices, updates estimates, refines high-priority simplices, and returns the integration result.
The package exports:
adaptivesimplex::coreadaptivesimplex::cutadaptivesimplex::adaptive
The adaptive layer depends on core, but it does not depend on cut. Link
adaptivesimplex::cut when your code directly includes or uses the cut-simplex
utility.
Public headers are under include/adaptivesimplex:
#include <adaptivesimplex/core.h>
#include <adaptivesimplex/cut.h>
#include <adaptivesimplex/adaptive.h>
#include <adaptivesimplex/adaptivesimplex.h>After installing AdaptiveSimplex:
find_package(adaptivesimplex CONFIG REQUIRED)
add_executable(my_integrator main.cpp)
target_compile_features(my_integrator PRIVATE cxx_std_20)
target_link_libraries(my_integrator PRIVATE adaptivesimplex::adaptive)If your code uses cut-simplex moments directly:
target_link_libraries(
my_integrator PRIVATE
adaptivesimplex::cut
adaptivesimplex::adaptive
)With CMake FetchContent:
include(FetchContent)
FetchContent_Declare(
adaptivesimplex
GIT_REPOSITORY <repository-url>
GIT_TAG v0.1.0
)
FetchContent_MakeAvailable(adaptivesimplex)
target_link_libraries(my_integrator PRIVATE adaptivesimplex::adaptive)When used as a subproject, tests, examples, benchmarks, and documentation tools default to off. Main CMake options:
ADAPTIVESIMPLEX_BUILD_TESTINGADAPTIVESIMPLEX_BUILD_EXAMPLESADAPTIVESIMPLEX_BUILD_BENCHMARKSADAPTIVESIMPLEX_BUILD_DOC_TOOLSADAPTIVESIMPLEX_ENABLE_OPENMP
User-facing examples:
examples/level_set_integral.cpp: adaptive integration of a level-set indicator usingadaptivesimplex::cutexamples/two_peak_integral.cpp: smooth two-peak integral, useful as a counterexample where tensor grids can be competitiveexamples/scalar_integral.cpp: minimalsimplex_integrandsetupexamples/vector_integral.cpp: vector-valued result with explicit policies
Build and run examples:
cmake -S . -B build/examples -DADAPTIVESIMPLEX_BUILD_EXAMPLES=ON
cmake --build build/examples
build/examples/adaptivesimplex_level_set_integral
build/examples/adaptivesimplex_two_peak_integralcmake -S . -B build/local \
-DADAPTIVESIMPLEX_BUILD_TESTING=ON \
-DADAPTIVESIMPLEX_BUILD_EXAMPLES=ON \
-DADAPTIVESIMPLEX_BUILD_BENCHMARKS=ON
cmake --build build/local
ctest --test-dir build/local --output-on-failureCI runs the package check on Ubuntu GCC, Ubuntu Clang, and macOS Clang. The package check builds tests/examples/benchmarks, runs the tests, smoke-runs the benchmarks, installs the package, and builds a downstream package consumer.
OpenMP is optional. If CMake finds it and ADAPTIVESIMPLEX_ENABLE_OPENMP is on,
adaptivesimplex::adaptive links OpenMP::OpenMP_CXX and the missing-vertex
loop in adaptive/evaluation.h can run in parallel.

