Serialization
A model is data: a network round-trips through a single eval-free JSON document. Rate/attribute expressions are carried by the closed, typed ExprNode IR (never a captured host closure), host functions are referenced by name through a per-network registry, and load never evals a model field — the typed IR plus validation is the trust boundary that closes the remote-code-execution surface. from_json_model/to_json_model are the file-level round-trip; the *_to_dict/*_from_dict and build_network_from_dict helpers expose the intermediate dict form; @import_model/@export_model are the authoring-macro wrappers.
The concrete JSON document shape is documented on the JSON model schema page.
Loading routes through the (unexported) validate pass, which checks a candidate document against the closed IR and returns diagnostics rather than ever evaluating a field; call it as ReactiveDynamics.validate(dict) when inspecting a document directly.
ReactiveDynamics.from_json_model — Function
from_json_model(json::AbstractString; seed = nothing, registry = Dict{Symbol, Any}(), population = []) -> ReactionNetworkProblemParse a single-JSON model document into a runnable ReactionNetworkProblem — the public IMPORT half of the round-trip (its inverse is to_json_model). Pipeline: JSON.parse → validate (gates on isempty(diags), else a hard error listing the diagnostics) → build_network_from_dict → construct. Eval-free throughout: a model IS data. meta.tspan (the simulation horizon) is REQUIRED; meta.seed supplies seed when the kwarg is nothing. rules[] (ADR 0010) become typed Rules and inputs[] (ADR 0012 §B1) become the declared external read ports + pre-wire defaults. Host functions named by an action/genesis kind are resolved through registry; the AA wiring that fills inputs[] ports is host-side, never in the document.
ReactiveDynamics.to_json_model — Function
to_json_model(net::ReactionNetwork; meta = Dict{String, Any}()) -> String
to_json_model(prob::ReactionNetworkProblem; meta = Dict{String, Any}()) -> StringSerialize a model to a single JSON string — the public EXPORT half of the round-trip (its inverse is from_json_model; to_json_model ∘ from_json_model reconstructs an equivalent model). Delegates to model_to_dict then JSON.json. Given a constructed ReactionNetworkProblem it also round-trips the problem-level state the net does not carry: the typed Rules (via rules[]), the declared external-input ports + defaults (via inputs[]), and — reconstructed into meta from the solver fields when the caller supplies none — tspan/dt/seed, so the exported document is COMPLETE and re-importable on its own (from_json_model requires meta.tspan). An explicit meta always wins. Host functions are referenced by NAME through the registry, never embedded.
ReactiveDynamics.@import_model — Macro
@import_model "model.rdj.json"
@import_model "model.rdj.json" prob registry=Dict(:Kind=>ctor)Load a model from an eval-free model.rdj.json document (ADR 0005) and construct a ReactionNetworkProblem. The document is inert data: it is validated and lowered through the typed ExprNode IR, never eval'd. Host token kinds / callbacks are supplied BY NAME through the registry (ADR 0006 §C); the file alone cannot execute code.
Examples
@import_model "pipeline.rdj.json" prob
prob = from_json_model(read("pipeline.rdj.json", String); seed = 1, registry = REG)ReactiveDynamics.@export_model — Macro
@export_model prob "model.rdj.json"Serialize a model to an eval-free model.rdj.json document (ADR 0005).
Examples
@export_model prob "pipeline.rdj.json"ReactiveDynamics.validate — Function
validate(d::AbstractDict; registry = Dict{Symbol, Any}()) -> Vector{Diagnostic}Statically check a parsed model dict d against the closed, eval-free IR and return a Vector{Diagnostic} — it never runs a model field, so it is the trust boundary from_json_model gates on (construction proceeds only when the returned vector is empty). Checks include: every NodeRef names a declared species/param/observable, every Call op is in OP_WHITELIST and every Sample distribution in DIST_WHITELIST, reference kinds are in REF_KINDS, and a non-time-varying attribute is a bare literal rather than a non-trivial tree. Host functions named by an action/genesis kind are resolved by name through registry. Unexported; call it as ReactiveDynamics.validate(dict) to inspect a document directly.
ReactiveDynamics.node_to_dict — Function
node_to_dict(n::ExprNode) -> Dict{String, Any}Serialize one ExprNode to its node-tagged JSON dict — the recursive half of the eval-free (de)serialization. Each dict carries a "node" tag ("const"/"ref"/"call"/"sample"/"timeref"/"choose"/"field"/"externalref") plus that node's fields, with child nodes serialized recursively; a Symbol-valued Const is flagged so node_from_dict can recover it as a Symbol rather than a string. Never emits a Julia source string. The inverse is node_from_dict.
ReactiveDynamics.node_from_dict — Function
node_from_dict(d::AbstractDict) -> ExprNodeParse a node-tagged JSON dict back to the typed ExprNode it denotes — the inverse of node_to_dict and the recursive half of the eval-free deserialization. Dispatches on the "node" tag; child nodes are parsed recursively. NEVER Meta.parse/evals — a "const" string is recovered as a Symbol only when its "symbol" flag is set (else Int/Float64/Bool per the JSON scalar), and an unknown tag is a hard error. An out-of-whitelist op/dist is NOT caught here (it round-trips as a Symbol) — validate is the whitelist gate.
ReactiveDynamics.model_to_dict — Function
model_to_dict(net::ReactionNetwork; meta = Dict{String, Any}(), rules = [], inputs = Dict{Symbol, Any}()) -> Dict{String, Any}The EXPORT envelope: emit the JSON dict of a static ReactionNetwork — every top-level array build_network_from_dict reads back (params[]/species[]/transitions[]/reactants[], plus observables[]/rules[]/inputs[] when present), and hence its structural inverse. Each stored attribute Expr is lowered back to an ExprNode dict via from_expr, with the net's species/param NAME sets threaded through so a bare symbol classifies to the right NodeRef kind (matching how the DSL named it). rules/inputs are passed through by to_json_model(::ReactionNetworkProblem) since they live on the problem, not the net; legacy :E event rows and RawExpr actions are intentionally not emitted (not JSON-serializable). Called by to_json_model.
ReactiveDynamics.build_network_from_dict — Function
build_network_from_dict(d::AbstractDict; registry = Dict{Symbol, Any}()) -> ReactionNetworkBuild a static ReactionNetwork store from a parsed model dict d, eval-free. Reads the top-level params[]/species[]/transitions[]/reactants[]/observables[] arrays, lowering each attribute ExprNode (or bare literal) to the Expr column the constructor consumes via to_expr — param values are JSON numbers taken verbatim (never eval'd), and a transition's reactants[] assemble into its :trans reaction-line Expr. Assumes d has already passed validate; it is the structural core of from_json_model and the inverse of model_to_dict. Host functions referenced by an action/genesis kind are resolved by NAME through registry, never carried in d.
The ExprNode IR
The closed, eval-free expression IR: every rate/attribute expression is one of these typed node kinds, with to_expr/from_expr converting between a node tree and a Julia Expr. OP_WHITELIST, DIST_WHITELIST, and REF_KINDS are the closed vocabularies the IR (and validation) admit.
ReactiveDynamics.ExprNode — Type
ExprNodeAbstract supertype of the closed, eval-free expression IR (ADR 0005). Every time-varying attribute (rate, stoich, cycletime, probofsuccess, priority, …) and action value is authored — by a human or an LLM — as a tagged-union tree of ExprNodes, NEVER as a Julia source string. The union is CLOSED (the concrete leaves/nodes below) and its operator/distribution/reference vocabulary is fixed by the OP_WHITELIST/DIST_WHITELIST/REF_KINDS whitelists, so a model can be (de)serialized and validated without ever Meta.parse/eval-ing a field — those whitelists are the only Julia ever produced from an inert model document (the trust boundary). to_expr lowers a tree to EXACTLY the Expr the DSL produces (compiled to a closure ONCE at construction); from_expr is the structural inverse. The runtime hot path is untouched — this is purely the authoring/serialization boundary.
ReactiveDynamics.Const — Type
Const(value)A scalar literal leaf: a Float64, Int, Bool, or Symbol. Evaluates to value — a Symbol lowers to a QuoteNode (a literal like :Phase2, e.g. a lifecycle phase), everything else to the bare value.
ReactiveDynamics.NodeRef — Type
NodeRef(kind, name)A named reference to a declared model entity — kind ∈REF_KINDS(:species/:param/:obs) selects which namespacenamelives in. Lowers to the barenameSymbol;wrapfun/compileattrsthen substitute a species →state.u[i]and a param →state.p[:name]at compile time. It isNodeRef, NOTRef— a distinct IR leaf, not Julia'sBase.Ref`.
ReactiveDynamics.Call — Type
Call(op, args)A whitelisted operator application: op ∈OP_WHITELIST over child-ExprNode args. Evaluates to op(args...). Short-circuit boolean ops (:&&/:||) lower to an Expr HEAD (and take exactly two args); every other op lowers to an ordinary :call.
ReactiveDynamics.Sample — Type
Sample(dist, args)A distribution draw: dist ∈DIST_WHITELIST with ExprNode parameter args. Lowers to rand(state.rng, dist(args...)), so the draw routes through the state-owned RNG (§4 D5) — the stochastic core stays deterministic under (model, seed).
ReactiveDynamics.TimeRef — Type
TimeRef()The current-simulation-time leaf. Lowers to the reserved @t() macrocall, which wrap_fun rewrites to t(state).
ReactiveDynamics.Choose — Type
Choose(alts)A weighted choice over (weight, value) alternatives (alts::Vector{Tuple{Float64, ExprNode}}). Lowers to the @choose((w1, v1), (w2, v2), …) macrocall the reaction-line resolver consumes.
ReactiveDynamics.Field — Type
Field(name)A bound-token field read (ADR 0008 §D): resolved against the firing instance's bound token at apply time. Lowers to @field(name). Legal ONLY in a SetField/@advance value context (validate rule 7) — there is no bound token elsewhere.
ReactiveDynamics.ExternalRef — Type
ExternalRef(port)A latched read of a declared inputs[] port (ADR 0012 §B2) — a closed leaf, sibling of NodeRef/TimeRef. Lowers to state.external_inputs[:port], returning the value pinned at _prestep! (the foreign source's previous-tick-boundary projection), so it is 𝓕ₜ-measurable and RNG-free and a rate and a guard reading the same port in one tick agree (Invariant 2). The port is a NAME only; the foreign-agent topology that fills it lives host-side in add_wire!, never in the RD document (Invariant 4). An undeclared port is a validate rule-8 diagnostic, never an eval.
ReactiveDynamics.to_expr — Function
to_expr(n::ExprNode)Lower a typed IR node to EXACTLY the Julia Expr (or bare literal/Symbol) the @reaction_network DSL and compile_attrs consume — the only place Julia is produced from an inert model, and hence the eval-free trust boundary. Each concrete node lowers as documented on its type: Const → literal/QuoteNode, NodeRef → bare Symbol, Call → whitelisted call/short-circuit Expr, Sample → rand(state.rng, …), TimeRef → @t(), Choose → @choose(…), Field → @field(name), ExternalRef → state.external_inputs[:port]. A Call/Sample whose op/dist is not in OP_WHITELIST/DIST_WHITELIST is a hard error. The structural inverse is from_expr.
ReactiveDynamics.from_expr — Function
from_expr(ex; species = Set{Symbol}(), params = Set{Symbol}()) -> ExprNodeStructural inverse of to_expr: lower a DSL-authored attribute Expr back to a typed ExprNode tree (so a DSL/loaded model can be serialized to JSON). Bare symbols are classified via the known species/params name sets — a symbol in params becomes a NodeRef(:param, …), otherwise a NodeRef(:species, …) (the default for an unclassified bare symbol). Recognizes the exact lowered shapes to_expr emits — rand(state.rng, Dist(…)) → Sample, state.external_inputs[:port] → ExternalRef, the @t/@field/@choose macrocalls, short-circuit boolean heads — and rejects a call head outside OP_WHITELIST. Result nodes are NodeRefs (not Julia Refs).
ReactiveDynamics.OP_WHITELIST — Constant
OP_WHITELISTThe closed tuple of operator Symbols a Call node may carry (arithmetic, comparison, boolean, and a few unary math functions). Part of the eval-free trust boundary: to_expr refuses any Call op outside this set, and from_expr/validate reject an unknown op — so no arbitrary Julia function can enter a model from an inert document.
ReactiveDynamics.DIST_WHITELIST — Constant
DIST_WHITELISTThe closed tuple of distribution Symbols a Sample node may draw from. Part of the eval-free trust boundary: to_expr refuses any Sample dist outside this set, and from_expr/validate reject an unknown dist — the only distributions a model document can name.
ReactiveDynamics.REF_KINDS — Constant
REF_KINDSThe closed tuple of reference kinds a NodeRef may carry: :species, :param, :obs. validate checks a ref's kind is in this set AND that its name is declared in the corresponding pool — part of the eval-free trust boundary.