Why a TVB-O spec compiles rather than runs interpreted, and the three layers that turn one specification into backend-native code.
3Generate
TVB-O does not simulate anything. It writes the program that does, in the idiom of the backend you name, and then gets out of the way. render_code returns that program as text you can read, diff, edit and run without TVB-O installed.
That is a deliberate choice, and the alternative is worth naming. A framework that interprets a specification at run time has to carry every feature into every backend at run time, and the user gets a black box whose behaviour is only knowable by reading the framework. A framework that emits code produces an artefact: the thing that ran is on disk, in the target language, and a reviewer can check it.
import loggingimport jaxfrom tvbo.data.types import TimeSeriesimport jax.numpy as jnplogger = logging.getLogger("tvbo.run")def cfun(weights, history, current_state, p, delay_indices, t): n_node = weights.shape[0] b, a = p.b, p.a x_j = jnp.array( [ current_state[0, delay_indices[1]], ] ) pre = x_j pre = pre.reshape(-1, n_node, n_node)def op(x):return jnp.sum(weights * x, axis=-1) gx = jax.vmap(op, in_axes=0)(pre)return b + a * gximport jax.numpy as jnpdef dfun(current_state, t, cX, _p):# Parameters I = _p.I a = _p.a alpha = _p.alpha b = _p.b beta = _p.beta c = _p.c d = _p.d e = _p.e f = _p.f g = _p.g gamma = _p.gamma tau = _p.tau# Coupling c_glob = cX[0] local_coupling =0# State variables V = current_state[0] W = cu…
Three layers
Generation splits into three jobs, and keeping them apart is what stops a backend from becoming unmaintainable.
Adapter tvbo/adapters/<backend>.py resolve the spec into typed context
Printer tvbo/codegen/code.py render one expression in the target language
Template tvbo/templates/<backend>/ lay out the program around it
The adapter resolves
BaseAdapter turns stringly-typed metadata into clean typed context, once: which dynamics sit on which node, how couplings resolve, what the weight matrix is, which distributions the initial state draws from, what the integrator needs. A backend subclasses it and states only what makes it different — its template tree, and a prepare_context override where the shared context will not do.
Everything ambiguous is decided here. A template that had to re-derive which state variable a coupling reads would be re-deriving it in every backend, and the three answers would diverge.
The printer speaks the target language
Equations are sympy expressions, and each target has a printer that renders them: JaxPrinter, JuliaPrinter, MTKPrinter, Brian2Printer, LEMSPrinter, CUDACodePrinter, FortranPrinter. One model’s rhs becomes jnp.tanh(...), tanh(...) or tanhf(...) depending only on which printer is asked.
This is why a model written once compares across backends at all. The equation is never re-typed per backend, so there is no opportunity for two backends to disagree about what the model is — only about how they integrate it, which is a separate and measurable question.
The template lays out the program
Templates are Mako trees, one per backend, and they express the shape of the emitted code — the module, the imports, the solve loop, the branches a feature needs. Structure lives in Mako <%def> partials rather than in Python string-building, because a 60-line function body assembled by "".join() is fragile about indentation, unreadable, and impossible to diff.
The backends
Each backend declares what it can do, and the declaration is what the workflow planner consults when deciding whether a sweep axis vectorizes inside the backend or fans out across jobs.
A backend that cannot render a task says so rather than silently substituting another. That is the contract that makes “run it on Julia” a checkable request instead of a hope.
Reading the generated code
The emitted program is the artefact, so read it when something surprises you — it is usually faster than reasoning about what the framework might have done.