Plugins
A plugin is a non-JAX component (a mesher, a tet filler, a solver) reached through apply and vjp, with declared inputs, outputs and differentiable inputs. cadjoint asks the registry for a kind and does not know whether the component runs in this process, in a container or behind a Kubernetes Service.
Example
from cadjoint.plugins import plugin_for_kind
solver = plugin_for_kind("thermal_solver")
print(solver.name, solver.version) # thermal_jaxfem 0.1.0
print(sorted(solver.capabilities.differentiable_inputs))
# ['cell_conductivity', 'conductivity', 'points', 'source']
print(solver.capabilities.supports("per_element_properties")) # True
print(solver.probe())
The Tesseract transports need the tesseract extra (uv sync --extra tesseract).
Plugin attributes
Plugins are Tesseracts: typed apply and vector_jacobian_product endpoints, lifted into a JAX primitive by tesseract-jax. tesseract-core and tesseract-jax encode arrays, speak HTTP, declare schemas and implement the autodiff primitive; cadjoint.plugins provides the kind vocabulary, the registry, and the configuration that maps a name to one Tesseract.from_* call.
| Attribute | Meaning |
|---|---|
| name | the registry key, e.g. thermal_jaxfem |
| kind | the slot it fills. cadjoint asks for the PluginKind values mesher, tetfill, tet_mesher, thermal_solver, elastic_solver, flow_solver, and the five pluggable kinds. The field is a plain string, so a plugin may declare a kind cadjoint does not know |
| version | what the running instance advertises, else what its tesseract_config.yaml declares |
| inputs / outputs | the schema tesseract-runtime publishes, never restated in Python. For a python plugin, the annotations of its contract’s Protocol |
| capabilities | derived from the schema: differentiable inputs and outputs, served endpoints, and named flags (per_element_properties, body_force, frozen_topology, vjp, …) |
| apply / vjp | the component’s own endpoints |
| as_jax() | tesseract_jax.apply_tesseract bound to this plugin, the JAX primitive with its custom_vjp. For a python plugin, the object’s own method |
| probe() | /health, the reported version, and a hash of the served schema |
Anything not defined on a plugin is forwarded to the Tesseract, so plugin.jacobian(...), plugin.jacobian_vector_product(...), plugin.abstract_eval(...) and plugin.server_logs() work without a wrapper.
Shipped plugins
| Name | Kind | Wraps | Boundary crossed | VJP |
|---|---|---|---|---|
mesher |
mesher |
the whole mesher: lattice field → surface → volume | a black box with no derivative | surface interpolation (below) |
tetfill |
tetfill |
TetGen alone, surface in / volume out | a discrete algorithm with no derivative | exact transpose of a gather |
thermal_jaxfem |
thermal_solver |
jax-fem Poisson solve | AD strategy: JAX cannot trace PETSc assembly | implicit adjoint, bit-identical to in-process |
elastic_jaxfem |
elastic_solver |
jax-fem linear elasticity + von Mises | same | same |
elastic_calculix |
elastic_solver |
CalculiX 2.23 Fortran, over a subprocess | language, licence (GPL-2 isolated), AD strategy | native *SENSITIVITY adjoint plus a correction for a Jacobian-variation term missing from ccx 2.23, within 2e-4 of finite differences |
tet_gmsh |
tet_mesher |
Gmsh (HXT, order 2) on a solid | licence (GPL-2-or-later isolated), a discrete topology decision | pass-through on node_positions |
flow_brinkman |
flow_solver |
the lattice-Boltzmann flow solver, pure JAX | none | its own custom_vjp, bit-identical in-process and served |
Two plugins share elastic_solver; [defaults] in plugins.toml decides which one answers. CalculiX cannot be traced, linked or relicensed, so the plugin boundary composes its native adjoint with JAX autodiff while it stays a subprocess. The simulator ecosystem survey ranks 31 further solvers (jwave, JAX-Fluids, MJX, …) that fit the same slots. The solver ABI and the ccx correction are in FEM integration.
Mesher boundaries
tetfill receives an extracted surface (points, triangles) and never sees the field. TetGen runs in PLC mode with -Y, which keeps input vertices verbatim as the leading output nodes, so the VJP is the transpose of a gather read off the parents table. Dual contouring stays upstream in JAX against the true SDF, so creases stay sharp. TET4 and TET10.
mesher takes a field sampled on a lattice and does extraction, Newton projection and TetGen or voxelization inside. Its VJP never looks in: a boundary vertex v lies on the zero set of the trilinearly interpolated samples, so the implicit function theorem gives
\[\frac{\partial v}{\partial f_i} = -\,w_i(v)\,\frac{\nabla f}{|\nabla f|^2}\]
The interpolation weights at the frozen vertices are the VJP rows, so any mesher (TetGen, fTetWild, Gmsh) can sit behind it, including one that does not preserve input vertices. The boundary is pinned to the interpolant’s zero set, which smears sharp features, and only the normal component of boundary motion is carried. TET4, HEX8 and TET10.
Use tetfill wherever TetGen’s vertex-preserving mode applies, and mesher for a black-box mesher or HEX8. The validation matrix is in tet vs hex.
tet_gmsh
tet_gmsh takes a solid (geometry plus geometry_format) and returns a TET10 mesh sized by the part. The default input is the dual-contour surface as an STL, which Gmsh’s classifySurfaces + createGeometry split into one reparametrised surface per smooth region: on a plate with a bore, three thousand facets in and a dozen surfaces out. geometry_format="step" meshes a STEP file with the same contract; it benefits from a finer input than the faceted STEP cadjoint writes.
Patch ownership is decided on the caller’s side by cadjoint.fem.gmsh.assign_ownership: for every Gmsh surface entity, |f_p| is evaluated on its nodes for every patch of the scene, and a patch whose worst node clears the bar owns the surface. Arity follows from the entity: one field on a surface, two on a curve, three at a corner. An STL’s nodes lie on facets, so a curved face sags by h²/8r (3.5e-3 on a 0.25 bore at a 0.083 cell, above a 2.7e-3 bar). Every boundary node is therefore Newton-projected with project_points first, and the projected position is kept only where it confirms more patches. On the plate at target_size=0.16 this moves 264 of 1 700 boundary nodes and removes all 265 spurious blend nodes; the worst radius ratio stays 0.293. The projection fixes ownership, not position: bore nodes land within 2.4e-3 of r = 0.25.
Topology is frozen at discovery, so the VJP with respect to node_positions is a pass-through. Moving nodes with the design is the node_map kind’s job.
Gmsh is GPL-2.0-or-later and cadjoint is Apache-2.0, so nothing under cadjoint/ imports Gmsh at module scope. The supported route is the cadjoint_tet_gmsh image, where the licence boundary is a process boundary; pip install 'cadjoint[gmsh]' links it into your process instead (the fem extra also pulls it in, because jax-fem imports it). In a scene, SimMesh(method="tet10", mesher="gmsh") routes through this slot and returns the same TetMesh node layout as TetGen; see meshing.
Transports
Only the PluginSpec knows where a plugin runs. transport is a closed set, normalised to PluginTransport; any other value is rejected.
| Transport | Call | Use |
|---|---|---|
local |
Tesseract.from_tesseract_api(api_path) |
default; same process, no Docker, no serialization |
container |
Tesseract.from_image(image) then serve() |
a built image, torn down when the plugin closes |
remote |
Tesseract.from_url(url) |
anything already serving: tesseract serve, tesseract-runtime serve, a Kubernetes Service |
python |
getattr(import_module(module), attribute) for object = "module:attribute" |
a component called inside a trace or once per compile; needs no tesseract-core |
A Tesseract suits a coarse component called once per optimizer step: a served round trip costs ~10 ms (see Limitations). It does not suit a component inside a trace. An in-process Tesseract round trip costs ~0.14 s, while a two-field Newton projection of 300 points is 0.4 ms, a curve-tracer step is sub-millisecond, and a vmap cannot contain a round trip at all. A Tesseract also lacks the jvp and batching semantics of the component’s own custom_vjp. Such a component declares a typed Protocol in cadjoint.plugins.contracts (frozen-dataclass payloads) and the registry binds it to either transport; its Tesseract form is a thin tesseract_api.py over the same object, which is how tet_gmsh is written.
options in a spec is forwarded unchanged to the constructor (timeout, environment, volumes, num_workers, gpus, …).
Runtime scratch directory
Every Tesseract endpoint call opens a run_<uuid>/logs/ directory under the runtime’s output path, which defaults to the working directory. The local transport passes output_path=cadjoint.plugins.runtime_scratch(): one directory per process under ~/.cache/cadjoint/tesseract-runs (override with $CADJOINT_TESSERACT_RUNS), removed at exit. A server started by hand needs TESSERACT_OUTPUT_PATH:
TESSERACT_API_PATH=cadjoint/fem/tesseracts/mesher/tesseract_api.py \
TESSERACT_OUTPUT_PATH="$HOME/.cache/cadjoint/tesseract-runs" \
TESSERACT_NAME=cadjoint_mesher TESSERACT_VERSION=0.1.0 \
tesseract-runtime serve --port 8000
The run logs are always written; they can only be redirected.
Pluggable kinds
Five kinds are slots for a component that re-solves a surface against the scene’s patch fields with a derivative: a STEP whose bore is one cylinder, a traced rather than sampled edge curve, a Gmsh mesh whose nodes follow the design. Nothing in this repository fills them. Such a component ships separately as ordinary cadjoint.plugins entry points, and cadjoint calls whatever the registry returns through the Protocol.
| Kind | Protocol | In | Out |
|---|---|---|---|
node_map |
NodeMap.positions |
scene, parameters, an OwnedNodes record |
(P, 3) positions, differentiable in the parameters |
feature_edges |
FeatureEdges.feature_edges |
scene, grid | an EdgeSet of polylines (NumPy, no derivative) |
brep |
BRepExtractor.extract |
scene, grid | the derived B-rep, opaque, never leaves the process |
step_export |
StepExporter.step_export |
scene, grid, path | a file, and the writer’s report |
drag |
Drag.drag |
scene, B-rep, handle, target | a DragOutcome |
node_map runs inside the jitted objective Optimization builds, so its custom_vjp must be a primitive of the caller’s program; this is why the python transport exists. Its input, OwnedNodes, holds per node a seed position, the owning patch indices, the arity, the Gmsh entity dimension, the midside block and the ownership bar.
CONTRACT_VERSION in cadjoint.plugins.contracts bumps whenever a Protocol or payload changes shape; a component with a stale contract_version is refused with a sentence. One provider exists as a separate distribution; installing it registers it through entry points, and a component of your own registered the same way is treated identically.
cadjoint.tier reports whether a kind is filled:
from cadjoint import tier
tier.status().flags() # {'node_map': False, 'feature_edges': False, ...}
tier.available("brep") # False
tier.component("step_export") # None: the caller degrades
tier.require("node_map") # raises TierUnavailable(<one sentence>)
tier.report() # versions + status, for a bug report
With the kinds unfilled, cadjoint compiles, meshes with TetGen or Gmsh, solves, inspects, exports VTK and faceted STEP, and draws feature edges from the lattice classifier. One thing is refused: a Gmsh-meshed study cannot be optimized, because its mesh is frozen geometry, and Optimization refuses it at declaration rather than returning a wrong gradient. mesher="tetgen", the default, is differentiable either way. The compile payload carries a tier field and GET /api/capabilities returns tier.status(), so the viewer labels which layer it draws.
Configuration
The config file is $CADJOINT_PLUGINS (a file, or a directory holding plugins.toml), else ~/.config/cadjoint/plugins.toml. A [plugins.<name>] table for an existing name replaces its spec; a new name adds a plugin. ${VAR} expands from the environment, and an unset variable is an error.
[defaults]
elastic_solver = "elastic_calculix"
feature_edges = "my_edges"
[plugins.thermal_jaxfem]
kind = "thermal_solver"
transport = "remote"
url = "http://thermal.cadjoint.svc.cluster.local:8000"
schema_hash = "sha256:251b6bd67db3f..." # see Probing
options = { timeout = 600.0 }
[plugins.elastic_calculix]
kind = "elastic_solver"
transport = "container"
image = "cadjoint_elastic_calculix:latest"
options = { environment = { CADJOINT_CCX = "/python-env/bin/ccx" } }
[plugins.acoustics]
kind = "acoustic_solver"
transport = "remote"
url = "http://${ACOUSTICS_HOST}:8000"
[plugins.my_edges]
kind = "feature_edges"
transport = "python"
object = "my_package.edges:COMPONENT"
A distribution registers plugins without a config file through the cadjoint.plugins entry point group; a value is a PluginSpec, a dict, or a callable returning one:
# in the third party's pyproject.toml
[project.entry-points."cadjoint.plugins"]
acoustics = "my_package.plugins:SPEC"
node_map = "my_package.plugins:NODE_MAP" # PluginSpec(transport="python", object=...)
tesseract-core 1.11’s Tesseract.from_url takes no header or credential argument, and cadjoint does not modify its HTTP session. Put authentication in front of the Service (an ingress, a sidecar, a service mesh) or use kubectl port-forward.
Writing a plugin
A plugin is a Tesseract package plus a spec. The package is a directory with tesseract_api.py, tesseract_config.yaml and a requirements or environment file; see cadjoint/fem/tesseracts/*, and tests/fem/test_tesseract_packaging.py for what the SDK validates. tesseract_api.py declares the schema cadjoint reads back:
from pydantic import BaseModel
from tesseract_core.runtime import Array, Differentiable, Float64, Int32
class InputSchema(BaseModel):
points: Differentiable[Array[(None, 3), Float64]]
cells: Array[(None, None), Int32]
conductivity: Differentiable[Array[(), Float64]]
class OutputSchema(BaseModel):
temperature: Differentiable[Array[(None,), Float64]]
def apply(inputs: InputSchema) -> OutputSchema: ...
def abstract_eval(abstract_inputs): ...
def vector_jacobian_product(inputs, vjp_inputs, vjp_outputs, cotangent_vector): ...
Register it through plugins.toml, an entry point, or for one process:
from cadjoint.plugins import PluginSpec, register_plugin
register_plugin(
PluginSpec(
name="my_thermal",
kind="thermal_solver",
transport="local",
api_path="my_package/thermal/tesseract_api.py",
),
default=True,
)
To fill a cadjoint kind, the package must declare every input the frozen chain sends for that kind and the output it reads; the chain checks this before tracing and names any missing field. elastic_calculix declares no body_force, so it cannot serve elastic_solver for the frozen chains but remains a backend for direct solves.
Building and serving
tesseract build cadjoint/fem/tesseracts/mesher # ~1 min
tesseract build cadjoint/fem/tesseracts/elastic_calculix # ~1.5 min
tesseract build cadjoint/fem/tesseracts/thermal_jaxfem # ~3 min
tesseract build cadjoint/fem/tesseracts/elastic_jaxfem # ~3 min
tesseract build cadjoint/fem/tesseracts/tet_gmsh
tesseract build cadjoint/fem/tesseracts/flow_brinkman # pure JAX, pip provider
| Image | Requirements provider | Payload | Size |
|---|---|---|---|
cadjoint_mesher |
pip | cadjoint + TetGen + SciPy on CPython 3.12 | 1.36 GB |
cadjoint_elastic_calculix |
conda | ccx 2.23 from conda-forge at /python-env/bin/ccx, pinned via CADJOINT_CCX |
2.57 GB |
cadjoint_thermal_jaxfem |
conda | jax-fem stack: PETSc/petsc4py 3.25.5, gmsh, fenics-basix, meshio | 5.51 GB |
cadjoint_elastic_jaxfem |
conda | same | 5.51 GB |
The jax-fem stack is not pip-installable on Linux (petsc4py has no PyPI wheels, gmsh has manylinux wheels for x86-64 only), and ccx is a Fortran binary. cadjoint is a local path dependency, which tesseract build stages into the build context.
tesseract serve <image> is what a Kubernetes Deployment should run; point a remote spec at the Service in front of it. tesseract serve a b c serves several Tesseracts together. Locally, tesseract-runtime serve --port <n> with TESSERACT_API_PATH set serves a package directly, as tests/plugins/ does.
Prebuilt images
Every push to main publishes the images to GHCR (.github/workflows/tesseract-images.yml). A container spec that names one pulls it on first use (cadjoint pulls before Tesseract.from_image, which does not):
[plugins.thermal_jaxfem]
kind = "thermal_solver"
transport = "container"
image = "ghcr.io/andrinr/cadjoint_thermal_jaxfem:latest"
| Tag | Moves when |
|---|---|
:latest |
every publish from main |
:<version> |
the version in tesseract_config.yaml, what probe() reports |
:sha-<commit> |
never; pin this for a reproducible run |
Images are multi-arch (linux/amd64, linux/arm64) except cadjoint_tet_gmsh, which is amd64 only because gmsh publishes no linux/aarch64 wheels; Docker on Apple Silicon runs it under emulation. Each architecture is built on a native runner and joined into one tag: the Dockerfile tesseract-core generates pins its build stage to $BUILDPLATFORM, so a cross-building buildx --platform would copy a host-architecture environment into the arm64 image.
Probing
Plugin.probe() calls the runtime’s /health, reads the advertised version, and hashes the published schema:
from cadjoint.plugins import get_plugin
probe = get_plugin("thermal_jaxfem").probe()
# PluginProbe(name='thermal_jaxfem', kind='thermal_solver', transport='local',
# status='ok', version='0.1.0', schema_hash='sha256:251b6b…', …)
With schema_hash in the spec, a redeployed component whose interface changed is rejected before a run. The hash covers field names, shapes, dtypes, Differentiable flags and requiredness, and is identical across the local and remote forms of a package. It excludes the endpoint list, because an in-process runtime serves a test endpoint a served one does not. TESSERACT_NAME / TESSERACT_VERSION make a hand-started server advertise a version.
Gradient paths and backends
gradient_path on an Optimization selects which frozen chain runs:
gradient_path |
Plugin kinds it resolves |
|---|---|
"direct" (default) |
none: in-process JAX and jax-fem |
"tesseract" |
mesher → thermal_solver/elastic_solver |
"tesseract-dc" |
tetfill → thermal_solver/elastic_solver |
SolverBackend (backend="jaxfem" | "tesseract" | "calculix") resolves the same way: "tesseract" takes the thermal_solver / elastic_solver kinds, and "calculix" names the elastic_calculix plugin.
Limitations
Remote overhead. Measured 2026-09-02 on macOS/arm64 with the
mesherpackage at 1285 nodes / 912 cells (74 KB in, 31 KB out), twelvejax.value_and_gradsteps,tesseract-runtime serveon loopback:In-process Served over HTTP 12 steps 6.57 s / 7.05 s 6.69 s / 7.14 s per step 548 ms / 587 ms 558 ms / 595 ms objective 342.8520334386417 → 342.8408938752647 identical, all 13 digits The wire adds 1.3–1.8%. That is the best case: no scheduler, TLS or queueing. A cluster adds network latency to every apply and VJP, twice per optimizer step per stage. On larger meshes the relative overhead falls, because the payload grows linearly and the solve faster.
No zero-size arrays over HTTP. tesseract-core 1.11 validates polymorphic dimensions as
PositiveInt. The mesher’s discovery mode (emptypoint_idsandcell_template) must run in-process; pass the topology it returns to the served instance. The frozen chains sendcell_conductivity/cell_youngs/body_forceas empty arrays on the single-material path, so athermal_solverorelastic_solvercannot runremotein a chain run; the mesher stage and direct solves can.TetGen topology is platform-dependent. The same field meshes to 182 points on macOS/arm64 and 185 in the Linux container, so a frozen TET4/TET10 topology does not transfer between host and container. HEX8 is deterministic across both.
API
plugins.get_plugin,plugins.plugin_for_kind,plugins.available_pluginsplugins.Plugin,plugins.Capabilities,plugins.PluginProbeplugins.PluginSpec,plugins.register_plugin,plugins.load_configplugins.runtime_scratchplugins.NodeMap,plugins.FeatureEdges,plugins.BRepExtractor,plugins.StepExporter,plugins.Drag,plugins.OwnedNodesenums.PluginKind,enums.PluginTransport,enums.GradientPathtier.status,tier.require,tier.reportfem.gmsh.assign_ownership