Plugins

Every non-JAX component behind one interface, running wherever you point it.

Real engineering pipelines die at tool boundaries — a Fortran solver here, a Rust kernel there, a mesher nobody can differentiate — and the gradients die with them. cadjoint calls those components plugins: a mesher, a tet filler, a solver, each reached through two operations, apply and vjp, and each declaring what it takes, what it returns, and which of its inputs carry a derivative.

A plugin is implemented as a Tesseract — typed apply and vector_jacobian_product endpoints, lifted into a JAX primitive by tesseract-jax — but that is an implementation choice, not the interface. What cadjoint’s own code sees is a kind: it asks for “whatever fills the thermal_solver slot” and never learns whether the answer is running in this process, in a container, or on a Kubernetes Service.

uv sync --extra tesseract

What a plugin is

name the registry key, e.g. thermal_jaxfem
kind the slot it fills. The ones cadjoint asks for are PluginKind: mesher, tetfill, tet_mesher, thermal_solver, elastic_solver, flow_solver, and the five in-process kinds node_map, feature_edges, brep, step_export, drag. The field stays a plain string, so a plugin may declare a kind cadjoint has never heard of
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 that 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, with whatever custom_vjp it carries
probe() /health, the reported version, and a hash of the served schema
from cadjoint.plugins import get_plugin, 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())

cadjoint.plugins is deliberately thin. It does not encode arrays, speak HTTP, declare schemas, or implement an autodiff primitive — tesseract-core and tesseract-jax do all of that. What is cadjoint’s is the kind vocabulary, the registry, and the configuration that maps a name to one Tesseract.from_* call. Anything not defined on a plugin is forwarded to the Tesseract itself, so plugin.jacobian(...), plugin.jacobian_vector_product(...), plugin.abstract_eval(...) and plugin.server_logs() work without a wrapper.

The plugins that ship

Name Kind Wraps Boundary crossed
mesher mesher the whole black-box mesher: lattice field → surface → volume the boundary everyone gives up on
tetfill tetfill TetGen alone, surface in / volume out a discrete algorithm with no derivative
thermal_jaxfem thermal_solver jax-fem Poisson solve AD strategy — JAX cannot trace PETSc assembly
elastic_jaxfem elastic_solver jax-fem linear elasticity + von Mises same
elastic_calculix elastic_solver CalculiX 2.23 Fortran, over a subprocess language, licence (GPL-2 isolated), AD strategy
tet_gmsh tet_mesher Gmsh (HXT, order 2) on a solid — the dual-contour surface as STL, or a STEP file licence (GPL-2-or-later isolated), a discrete topology decision
flow_brinkman flow_solver the prototype lattice-Boltzmann flow solver, pure JAX none yet — it exists to prove a solver with no mesh fits the same slot

Each solves its VJP differently. The jax-fem pair use an implicit adjoint — one transposed linear solve per cotangent — and produce gradients bit-identical to the in-process path. CalculiX uses its own native *SENSITIVITY discrete adjoint, plus a correction for a Jacobian-variation term missing from ccx 2.23, validated to 2e-4 of finite differences. tetfill’s is the exact transpose of a gather (TetGen’s -Y preserves input vertices verbatim); mesher’s is the surface-interpolation contract described in the two mesher boundaries. flow_brinkman differentiates its fixed point by the implicit function theorem inside a custom_vjp, and the same custom_vjp runs on both sides of the boundary, so its in-process and served gradients are bit-identical by construction.

Two plugins share the elastic_solver kind, so a default decides which one answers; [defaults] in plugins.toml changes it.

Where a plugin runs

A PluginSpec is the only thing that knows about location, and each transport is exactly one tesseract-core constructor:

transport PluginTransport the call it makes when
local PluginTransport.LOCAL Tesseract.from_tesseract_api(api_path) default; same process, no Docker, no serialization
container PluginTransport.CONTAINER Tesseract.from_image(image) then serve() a built image, torn down when the plugin closes
remote PluginTransport.REMOTE Tesseract.from_url(url) anything already serving: tesseract serve, tesseract-runtime serve, a Kubernetes Service
python PluginTransport.PYTHON getattr(import_module(module), attribute) for object = "module:attribute" a component called inside a trace or once per compile, where a round trip costs more than the work — see one contract, two transports

Unlike kind, transport is a closed set: it is normalised to a cadjoint.enums.PluginTransport when the spec is built, and anything else is refused with the four names listed. local still means “a Tesseract in this process”; python needs no tesseract-core at all.

options in a spec is forwarded verbatim to that constructor — timeout, environment, volumes, num_workers, gpus, whatever the installed tesseract-core accepts. cadjoint adds no arguments of its own.

Where the runtime writes

Every Tesseract endpoint call opens a run_<uuid>/logs/ scratch directory under the runtime’s output path. Unconfigured, that path is the working directory of whichever process runs the endpoint — which is why a tesseract-runtime serve started from a checkout fills it with run_* directories, and why an in-process client leaves an unmanaged mkdtemp behind. PluginSpec owns this:

  • the local transport passes output_path=cadjoint.plugins.runtime_scratch() to Tesseract.from_tesseract_api;
  • runtime_scratch() is one directory per process under ~/.cache/cadjoint/tesseract-runs (override with $CADJOINT_TESSERACT_RUNS), removed at exit — so a thousand-call optimization does not outlive itself on disk;
  • a server you start yourself needs the same thing from the other side: set TESSERACT_OUTPUT_PATH (or run it with a working directory outside the repository).
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

There is no switch that turns the run logs off — the runtime’s file backend writes them unconditionally — so redirecting them is the whole of the fix. tests/plugins/test_scratch.py runs a plugin from an empty temporary working directory and asserts nothing is left in it.

Configuring: plugins.toml

Found at $CADJOINT_PLUGINS (a file, or a directory holding plugins.toml), else ~/.config/cadjoint/plugins.toml. A [plugins.<name>] table for a name that already exists replaces its spec; a new name adds a plugin.

# Which plugin fills each slot when a caller asks by kind.
[defaults]
elastic_solver = "elastic_calculix"

# The thermal solver moves to the cluster. Nothing in cadjoint changes.
[plugins.thermal_jaxfem]
kind = "thermal_solver"
transport = "remote"
url = "http://thermal.cadjoint.svc.cluster.local:8000"
schema_hash = "sha256:251b6bd67db3f..."   # the staleness fence, see below
options = { timeout = 600.0 }

# CalculiX stays on this machine, but in its own container.
[plugins.elastic_calculix]
kind = "elastic_solver"
transport = "container"
image = "cadjoint_elastic_calculix:latest"
options = { environment = { CADJOINT_CCX = "/python-env/bin/ccx" } }

# A third-party solver, added rather than replacing anything.
[plugins.acoustics]
kind = "acoustic_solver"
transport = "remote"
url = "http://${ACOUSTICS_HOST}:8000"

${VAR} expands from the environment when the spec is built, and an unset variable is an error rather than an empty value. A distribution can also register plugins without a config file, through the cadjoint.plugins entry point group:

# in the third party's pyproject.toml
[project.entry-points."cadjoint.plugins"]
acoustics = "my_package.plugins:SPEC"       # a PluginSpec, a dict, or a callable

Authentication. tesseract-core 1.11’s Tesseract.from_url takes a URL, an output path and a timeout — there is no header or credential argument, and cadjoint deliberately does not reach into its HTTP session to add one. Put authentication in front of the Service (an ingress, a sidecar, a mesh) or reach it through kubectl port-forward.

Writing one

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 any of cadjoint/fem/tesseracts/* for a worked example, and tests/fem/test_tesseract_packaging.py for what the SDK validates.

The tesseract_api.py declares its schema; that declaration is what cadjoint reads back as inputs, outputs and capabilities:

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): ...

Then register it — permanently through plugins.toml or an entry point, or in 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 up front and names the missing field, rather than letting a validation error surface from inside a traced call. elastic_calculix is the live example: it declares no body_force, so it cannot serve elastic_solver for the frozen chains (it is still a full backend for direct solves).

Building and serving

tesseract build turns a package directory into a Docker image with no extra glue:

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         # Gmsh behind the GPL boundary
tesseract build cadjoint/fem/tesseracts/flow_brinkman    # pure JAX, pip provider
Image Requirements provider The non-obvious payload Size
cadjoint_mesher pip cadjoint + TetGen + SciPy on a uv-installed CPython 3.12 1.36 GB
cadjoint_elastic_calculix conda the ccx 2.23 Fortran binary from conda-forge at /python-env/bin/ccx, pinned via CADJOINT_CCX 2.57 GB
cadjoint_thermal_jaxfem conda the full jax-fem stack: PETSc/petsc4py 3.25.5, gmsh, fenics-basix, meshio 5.51 GB
cadjoint_elastic_jaxfem conda same 5.51 GB

One package uses pip, three use the SDK’s conda provider — because petsc4py publishes no PyPI wheels at all and gmsh publishes manylinux wheels for x86-64 only, so the jax-fem stack is simply not pip-installable on Linux, and ccx is a Fortran binary no Python requirement can supply. In every case cadjoint itself is a local path dependency, which tesseract build stages into the build context automatically.

For a long-lived server, tesseract serve <image> is the SDK’s own command and is what a Kubernetes Deployment should run; point a remote spec at the Service in front of it. The SDK also serves several Tesseracts together (tesseract serve a b c), which is the natural shape when the mesher and the solver live on the same node. Locally, tesseract-runtime serve --port <n> with TESSERACT_API_PATH set will serve a package directly — that is what tests/plugins/ uses, and setting TESSERACT_NAME / TESSERACT_VERSION is what makes the served instance advertise a version for probe() to check.

Staleness: probe()

A long-lived remote is the case where “which component am I actually talking to?” stops being rhetorical. Plugin.probe() calls the runtime’s /health, reads the version it advertises, and hashes the schema it publishes:

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…', …)

Put that schema_hash in the spec, and a redeployed cluster that changed the component’s interface is refused before a run instead of failing inside a traced call. The hash covers the schema sections the runtime itself returns — field names, shapes, dtypes, Differentiable flags, requiredness — and is measured identical across the local and remote forms of the same package. It deliberately excludes the endpoint list: an in-process runtime serves a test endpoint that a served one does not.

The two mesher boundaries

mesher and tetfill both make meshing differentiable, but they cut at different places, and the choice is a real trade-off.

tetfill wraps only TetGen. The surface arrives already extracted, as points plus triangles, so it never sees the field at all. TetGen runs in PLC mode with -Y, which preserves input vertices verbatim as the leading output nodes; the VJP is then the exact transpose of a gather, read off the parents table. Because dual contouring stays upstream in JAX against the true SDF, the boundary keeps its creases sharp. It supports TET4 and TET10.

mesher wraps the whole pipeline. It takes an implicit field sampled on a lattice and returns a volume mesh, doing DC 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 lattice 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 vertex locations are the VJP rows. That makes any mesher differentiable from its inputs and outputs alone, even one that does not preserve its input vertices. The price is that the boundary is pinned to the interpolant’s zero set rather than the true field’s, which smears sharp features; and only the Hadamard-meaningful normal component of boundary motion is carried. It supports TET4, HEX8, and TET10.

So: reach for tetfill wherever TetGen’s vertex-preserving mode applies, and mesher when the mesher is a genuine black box or you need the HEX8 path. The validation matrix for both is in tet vs hex.

tet_gmsh cuts somewhere else again: at the geometry. It takes a solidgeometry plus geometry_format — and returns a TET10 mesh sized by the part instead of by a lattice. The public input is the dual-contour surface written as an STL, which Gmsh’s classifySurfaces + createGeometry splits into one reparametrised surface per smooth region along the lattice’s own feature cells; on the plate that is three thousand facets in and a dozen CAD surfaces out. geometry_format="step" takes the same road from a STEP file — the same mesher, the same contract — and is worth a finer input than the faceted STEP this package writes.

What it does not decide is which patch owns a node. That is cadjoint.fem.gmsh.assign_ownership, on the caller’s side, and it is a residual test and nothing else: for every Gmsh surface entity |f_p| is evaluated on its nodes for every patch of the scene’s public decomposition, and a patch whose worst node clears the bar owns the surface. Arity falls out of the entity — one field on a surface, two on a curve, three at a corner. The result is the OwnedNodes record above.

Because an STL’s nodes lie on facets, a curved face sags by h²/8r — 3.5e-3 on a 0.25 bore at a 0.083 cell, above a bar of 2.7e-3, so the bore would read as a blend. Every boundary node is therefore first Newton-projected onto the scene with the public arity-1 project_points, clamped at the bar, and the projected position is kept only where it confirms more patches than the facet position did. Measured on the plate at target_size=0.16: 264 of 1 700 boundary nodes move, 265 blend nodes and 2 blend surfaces become 0 and 0, and the worst radius ratio is unchanged at 0.293 (0.308 from an analytic STEP of the same part). It buys ownership, not position — the bore’s nodes and midsides land within 2.4e-3 of r = 0.25, under the bar but not on the cylinder.

Putting them on it, and moving them when the design moves, is the node_map kind’s job, and nothing here fills it. The topology is frozen at discovery and only positions move, which is what makes the vector_jacobian_product w.r.t. node_positions an exact pass-through rather than an approximation.

Gmsh is GPL-2.0-or-later and cadjoint is Apache-2.0, so it is not a core dependency and nothing under cadjoint/ imports it at module scope. (The fem extra does already pull it in, because jax-fem imports it — the gmsh extra exists so the mesher can declare what it needs without dragging a solver stack along.) The supported route is the cadjoint_tet_gmsh image, where the licence boundary is a process boundary — the same arrangement elastic_calculix uses. Installing pip install 'cadjoint[gmsh]' links it into your own process instead, which is a choice to make knowingly. tetfill remains the no-dependency fallback, and it stays the default.

The scene-program hook is SimMesh(method="tet10", mesher="gmsh"), which routes the build through the tet_mesher slot and returns the same TetMesh in the same node layout as sdf_to_tet_mesh followed by tet10_mesh — boundary corners, interior corners, midside block — so nothing downstream of a mesh changes. See meshing. The one thing that does change is the derivative: a Gmsh mesh’s nodes follow the design through the node_map kind, and without it the mesh is frozen geometry — it meshes, solves, inspects and exports, and Optimization refuses at declaration.

One contract, two transports

A Tesseract is the right contract for a coarse component: arrays in, arrays out, one apply and one vector_jacobian_product per optimizer step. The measurements above put a served round trip at ~10 ms, which is 1.3–1.8% of a 548 ms step — nothing.

It is the wrong contract for a component called inside a trace or once per compile. An in-process Tesseract apply/VJP round trip costs ~0.14 s; a two-field Newton projection of 300 points is 0.4 ms, one step of a curve tracer is sub-millisecond and hundreds run per overlay, and a vmap of a kernel cannot contain a round trip at all. A Tesseract also gives you a VJP but not the jvp and batching semantics of the component’s own custom_vjp, and tesseract-core 1.11 refuses zero-size arrays over HTTP.

So a component declares one interface and the registry binds it to either transport. The interface is a typed Protocol in cadjoint.plugins.contracts, with frozen-dataclass payloads; the Tesseract form of the same component is a thin tesseract_api.py over the same object, which is exactly how tet_gmsh is written.

The five in-process kinds

Five kinds are declared here and filled by nothing in this repository. They are the slots for a component that solves against the scene’s patch fields with a derivative — the work that turns a faceted surface back into the geometry it was cut from. cadjoint never imports such a component: it asks the registry for a kind and calls the object it gets back through the Protocol, so anything registering the entry points below fills the slot.

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 is the centrepiece and the reason the transport exists: it runs inside the jitted objective Optimization builds, so its custom_vjp has to be a primitive of the caller’s own program. Its input is OwnedNodes — per node a seed position, the global patch indices that own it, the arity, the Gmsh entity dimension, the midside block and the bar ownership was decided at — which cadjoint.fem.gmsh.assign_ownership, on this side of the interface, produces from a residual test against the scene’s own patch fields, no graph involved.

# what a distribution filling these kinds registers
[project.entry-points."cadjoint.plugins"]
node_map      = "my_package.plugins:NODE_MAP"     # PluginSpec(transport="python", object=...)
feature_edges = "my_package.plugins:FEATURE_EDGES"

cadjoint.plugins.contracts.CONTRACT_VERSION bumps whenever a Protocol or payload changes shape, and every component carries a contract_version, so a stale build is refused with a sentence rather than failing inside a trace.

Installing a provider is all it takes — the entry points do the registration, and nothing in cadjoint changes. One provider for these kinds exists as a separate distribution outside this project; a component of your own, registered the same way, is indistinguishable to the registry.

A plugins.toml is needed only to override what discovery found — to point one kind somewhere else, or to turn one off without uninstalling:

# ~/.config/cadjoint/plugins.toml

# The node map is a Tesseract on the cluster for a fully remote chain; every
# other kind stays the in-process object the entry point registered.
[plugins.node_map]
kind      = "node_map"
transport = "remote"
url       = "http://node-map.cadjoint.svc.cluster.local:8000"

# A second provider, chosen by name rather than by what happens to be installed.
[defaults]
feature_edges = "my_edges"

[plugins.my_edges]
kind      = "feature_edges"
transport = "python"
object    = "my_package.edges:COMPONENT"

When a kind is unfilled

cadjoint.tier is the one module that answers “is anything filling this kind” and writes the one refusal message.

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

Unfilled is how cadjoint ships and nothing breaks: it compiles, meshes with TetGen or Gmsh, solves, inspects, exports VTK and faceted STEP, and draws feature edges from the lattice classifier. What it refuses is a design derivative through a Gmsh mesh, which Optimization reports at declaration rather than discovering inside the trace. The compile payload carries a tier field and GET /api/capabilities returns tier.status(), so the viewer names the layer it is drawing rather than leaving you to guess.

Where plugins are used

gradient_path on an Optimization selects which frozen chain runs, and both chains resolve every stage through the registry:

gradient_path plugin kinds it resolves
"direct" (default) none — in-process JAX and jax-fem throughout
"tesseract" (alias "plugins") mesherthermal_solver/elastic_solver
"tesseract-dc" (alias "plugins-dc") tetfillthermal_solver/elastic_solver

The "tesseract" spellings are the documented ones and are not going away; the aliases exist because what the paths actually select is a plugin per stage. SolverBackendbackend="jaxfem" | "tesseract" | "calculix" — resolves the same way: "tesseract" takes the thermal_solver / elastic_solver kinds, and "calculix" names the elastic_calculix plugin.

Transport limits, honestly

Remote costs about ten milliseconds a step, on a small payload. Measured 2026-09-02 on macOS/arm64, the mesher package at 1285 nodes / 912 cells (a 74 KB field in, a 31 KB point array out), twelve jax.value_and_grad steps, tesseract-runtime serve on 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

So the wire adds 1.3–1.8% here and changes no number at all. That is the best case: loopback, no scheduler, no TLS, no queueing, and a payload small enough that JSON+base64 encoding is not the cost. A real cluster adds network latency on every apply and every VJP — twice per optimizer step per stage — and a 12-step run with a mesher and a solver both remote pays that 48 times. Bigger meshes shift the balance the other way: the encoded payload grows linearly while the solve grows faster, so the relative overhead falls.

Zero-size arrays cannot cross the HTTP boundary. tesseract-core 1.11 validates polymorphic array dimensions as PositiveInt, so an empty (0, …) input is rejected. Two consequences, both real today:

  • The mesher’s discovery mode — empty point_ids and cell_template, which is how the frozen topology is first obtained — must run in-process. Pass the topology it returns to the served instance.
  • The frozen chains send cell_conductivity / cell_youngs / body_force as empty arrays on the single-material path, so a thermal_solver or elastic_solver cannot yet be moved to remote for a chain run. The mesher stage can, and the backends’ direct solves can. Sending the scalar path as a length-1 sentinel instead would lift this; it would also change the wire payload every existing caller sends, so it has not been done.

TetGen topology is platform-dependent. The same field meshes to 182 points on macOS/arm64 and 185 in the Linux container, so a frozen-topology promise made on the host does not transfer to a container or a cluster for TET4/TET10. HEX8 (voxelize + Newton-snap) is deterministic across both.

Why the boundary is load-bearing

CalculiX cannot be traced, linked, or relicensed — it is GPL-2 Fortran that speaks input decks. The plugin boundary is what lets its native adjoint compose with JAX autodiff while staying a subprocess.

The mesher VJP is a contract, not a computation. The surface-interpolation map is defined at the boundary from the inputs and outputs alone, which is what makes swapping TetGen, fTetWild, or gmsh behind it a zero-cost experiment.

And solvers are genuinely interchangeable. The simulator ecosystem survey ranks 31 further candidates (jwave, JAX-Fluids, MJX, …) that drop into the same slot — now by writing a spec, not by writing an import.

Further reading

  • FEM integration — the solver ABI, adjoint mechanics, the ccx sensitivity correction, and container conformance.
  • Tet vs hex meshing — the mesher validation matrix and the surface-interpolation VJP contract.
  • Optimization — how gradient_path selects which chain runs.