---
title: "Graph Builder"
---

## Introduction {.unnumbered}

This file formalizes the `GraphBuilder` class, which implements the separation of graph structure (topology and edge coefficients) from parameters (the vector $\boldsymbol{\theta}$) for efficient repeated evaluation. In inference workflows such as SVGD ([18]) and MCMC ([19]), the same graph structure is evaluated with hundreds or thousands of different parameter vectors. Parsing the JSON serialization and constructing vertices and edges from scratch for each $\boldsymbol{\theta}$ is wasteful when the topology is fixed.

The `GraphBuilder` addresses this by parsing the JSON representation once, caching the vertex states, edge connectivity, and coefficient vectors in C++ data structures, and then providing fast `build(theta)`, `compute_pmf(theta, times)`, and `compute_moments(theta, nr_moments)` methods that construct a concrete graph and evaluate it in a single call. This pattern---serialize once, evaluate many times---provides the performance backbone for the JAX FFI interface formalized in [25].

**Prerequisites:** [01], [11], [14], [15]

**Source files:**

- `src/cpp/parameterized/graph_builder.hpp` (class declaration)
- `src/cpp/parameterized/graph_builder.cpp` (implementation)

## Definitions {.unnumbered}

> **Recall** (@def-01-param-edges). *A parameterized edge $(v \to z)$ carries a coefficient vector $\mathbf{c} = (c_1, \ldots, c_d)$ and computes its weight as $w(v \to z; \boldsymbol{\theta}) = \sum_j c_j \theta_j$ (linear mode) or $w = \prod_j (c_j \theta_j)$ (log mode).*

> **Recall** (@def-14-uniformization). *Uniformization of a CPH graph with granularity $g$ produces a discrete-time chain enabling PDF computation via the forward algorithm.*

> **Recall** (@def-15-moment-replay). *The $k$-th moment $\mathbb{E}[\tau^k]$ is computed via $k$ successive applications of the expected waiting time computation on the graph.*

::: {#def-26-serialization .definition}

#### Graph Structure Serialization

*Let $G = (V, E, W)$ be a parameterized phase-type graph (@def-01-param-edges) with $p$ transient vertices, parameter length $d$, and state length $\ell$. The serialization of $G$ is a JSON object $J$ with the following fields:*

- *`states`: a $p \times \ell$ array of integer state vectors, $\texttt{states}[i] = \mathbf{s}_{v_i}$ for $i = 0, \ldots, p-1$.*
- *`edges`: a list of triples $(i, j, w)$ for each non-parameterized edge $(v_i \to v_j)$ with fixed weight $w$.*
- *`start_edges`: a list of pairs $(j, w)$ for each edge from the starting vertex $v_0$ with fixed weight $w$.*
- *`param_edges`: a list of tuples $(i, j, c_1, \ldots, c_d)$ for each parameterized edge $(v_i \to v_j)$ with coefficient vector $(c_1, \ldots, c_d)$.*
- *`start_param_edges`: a list of tuples $(j, c_1, \ldots, c_d)$ for each parameterized edge from $v_0$.*
- *`param_length`: $d$, the dimension of $\boldsymbol{\theta}$.*
- *`state_length`: $\ell$, the dimension of state vectors.*
- *`n_vertices`: $p$, the number of transient vertices.*
- *`weight_mode`: `"linear"` or `"log"`, the weight computation mode.*

*The serialization separates structural information (topology, coefficients) from parameter values ($\boldsymbol{\theta}$), enabling the structure to be fixed while $\boldsymbol{\theta}$ varies.*
:::

<details>
<summary>Intuition</summary>
The serialization is a snapshot of the graph's "skeleton": it records which vertices exist, how they are connected, and what coefficients each parameterized edge carries, but does not commit to specific parameter values. This is analogous to a compiled computation graph in machine learning frameworks, where the graph structure is fixed at compile time and only the input data varies at runtime.
</details>

<details>
<summary>Example</summary>
A two-state coalescent model with parameter $\theta$ (inverse population size) serializes as:
```json
{
  "states": [[2], [1]],
  "edges": [],
  "start_edges": [[0, 1.0]],
  "param_edges": [[0, 1, 1.0]],
  "start_param_edges": [],
  "param_length": 1,
  "state_length": 1,
  "n_vertices": 2,
  "weight_mode": "linear"
}
```
The single parameterized edge from vertex 0 to vertex 1 has coefficient $c_1 = 1.0$, so $w = 1.0 \cdot \theta_1 = \theta_1$.
</details>

::: {#def-26-graph-builder .definition}

#### GraphBuilder

*A GraphBuilder is a C++ object $\mathcal{B}$ initialized from a serialized graph structure $J$ (@def-26-serialization). It caches the parsed representation and provides the operation:*

$$\mathcal{B}.\operatorname{build}(\boldsymbol{\theta}) = G(\boldsymbol{\theta}),$$ {#eq-26-build}

*which returns a concrete (non-parameterized) graph $G(\boldsymbol{\theta}) = (V, E, W_{\boldsymbol{\theta}})$ where each edge weight is evaluated at $\boldsymbol{\theta}$:*

$$W_{\boldsymbol{\theta}}(v_i \to v_j) = \begin{cases}
w & \text{if } (v_i \to v_j) \text{ is a fixed edge with weight } w, \\
\sum_{k=1}^{d} c_k \theta_k & \text{if weight mode is linear}, \\
\prod_{k=1}^{d} (c_k \theta_k) & \text{if weight mode is log}.
\end{cases}$$ {#eq-26-weight-eval}

*The GraphBuilder additionally provides composite operations $\mathcal{B}.\operatorname{compute\_pmf}(\boldsymbol{\theta}, \mathbf{t})$ and $\mathcal{B}.\operatorname{compute\_moments}(\boldsymbol{\theta}, K)$ that combine graph construction with evaluation in a single call, avoiding the overhead of exposing the intermediate graph to Python.*
:::

<details>
<summary>Intuition</summary>
The GraphBuilder is a factory that stamps out concrete graphs from a template. The JSON parsing (which involves string processing, memory allocation, and validation) happens once at construction; each subsequent `build` call only performs the lightweight operations of creating vertices, adding edges with computed weights, and returning the graph. For a graph with $p$ vertices and $m$ edges, parsing costs $O(|J|)$ while building costs $O(p + m)$.
</details>

<details>
<summary>Example</summary>
In SVGD with 100 particles and 1000 iterations, the GraphBuilder is constructed once from the serialized graph. Each of the $100 \times 1000 = 100{,}000$ evaluations calls `build(theta)` + `pdf(t, g)`, reusing the cached vertex states and edge connectivity. Without caching, each evaluation would re-parse the JSON string.
</details>

::: {#def-26-batch-eval .definition}

#### Batch Evaluation

*Given a GraphBuilder $\mathcal{B}$ and a collection of parameter vectors $\boldsymbol{\theta}^{(1)}, \ldots, \boldsymbol{\theta}^{(B)} \in \mathbb{R}^d$, the batch evaluation is:*

$$\mathcal{B}.\operatorname{batch\_pmf}(\boldsymbol{\Theta}, \mathbf{t}) = \begin{pmatrix} h(\boldsymbol{\theta}^{(1)}, \mathbf{t}) \\ \vdots \\ h(\boldsymbol{\theta}^{(B)}, \mathbf{t}) \end{pmatrix} \in \mathbb{R}^{B \times n},$$ {#eq-26-batch}

*where $\boldsymbol{\Theta} = (\boldsymbol{\theta}^{(1)}, \ldots, \boldsymbol{\theta}^{(B)})^\top \in \mathbb{R}^{B \times d}$ is the parameter matrix. In the JAX FFI implementation, batch evaluation is triggered by `jax.vmap`, which adds a batch dimension to the theta buffer. The FFI handler processes each batch element independently, optionally in parallel via OpenMP threads.*
:::

<details>
<summary>Intuition</summary>
Batch evaluation is the workhorse of particle-based inference. In SVGD, each particle carries its own $\boldsymbol{\theta}^{(b)}$, and the log-likelihood and its gradient must be computed for all particles at each iteration. Batch evaluation via `vmap` + OpenMP parallelizes this across CPU cores, with each core running a separate `GraphBuilder::build` + forward algorithm sequence. Since each build creates an independent graph, there is no synchronization overhead.
</details>

<details>
<summary>Example</summary>
With 100 SVGD particles on an 8-core machine, `jax.vmap(compute_pmf_ffi)` dispatches 100 evaluations to the FFI handler, which processes them in batches of 8 (one per OpenMP thread), achieving approximately $8\times$ speedup over sequential evaluation.
</details>

## Theorems and Proofs {.unnumbered}

::: {#thm-26-equivalence .theorem}

#### GraphBuilder Equivalence

*Let $G = (V, E, W)$ be a parameterized phase-type graph with serialization $J = \operatorname{serialize}(G)$, and let $\mathcal{B} = \operatorname{GraphBuilder}(J)$. For any valid parameter vector $\boldsymbol{\theta} \in \mathbb{R}^d$:*

1. *The concrete graph $G(\boldsymbol{\theta}) = \mathcal{B}.\operatorname{build}(\boldsymbol{\theta})$ has the same vertex set $V$ (with the same state vectors), the same edge set $E$ (with the same connectivity), and edge weights satisfying @eq-26-weight-eval.*

2. *For any time points $\mathbf{t} \in \mathbb{R}^n_{\geq 0}$ and granularity $g$:*
$$\mathcal{B}.\operatorname{compute\_pmf}(\boldsymbol{\theta}, \mathbf{t}) = (f(t_1; G(\boldsymbol{\theta})), \ldots, f(t_n; G(\boldsymbol{\theta}))),$$ {#eq-26-pmf-equiv}
*where $f(t; G(\boldsymbol{\theta}))$ is the PDF computed by @alg-14-cph-uniformization on the concrete graph.*

3. *For any number of moments $K$:*
$$\mathcal{B}.\operatorname{compute\_moments}(\boldsymbol{\theta}, K) = (\mathbb{E}[\tau], \mathbb{E}[\tau^2], \ldots, \mathbb{E}[\tau^K]),$$ {#eq-26-moments-equiv}
*where the moments are computed by @alg-15-sojourn-cross-moments on the concrete graph.*

*In particular, the results are independent of the serialization/deserialization path: they depend only on the mathematical content of $G$ and $\boldsymbol{\theta}$.*
:::

::: {.proof}
We verify each claim.

**(1) Graph construction.** The `build` method (lines 118--194 of `graph_builder.cpp`) iterates over the cached `states_` array and creates one vertex per entry, preserving the state vectors from the serialization. It then iterates over `edges_`, `start_edges_`, `param_edges_`, and `start_param_edges_`, adding edges with weights computed by `compute_weight`. For fixed edges, the weight is stored directly. For parameterized edges, `compute_weight` evaluates @eq-26-weight-eval: in linear mode, it computes $\sum_k c_k \theta_k$; in log mode, it computes $\exp(\sum_k \log(c_k \theta_k)) = \prod_k (c_k \theta_k)$. The vertex indices in the serialization correspond to the creation order, and the `build` method creates vertices in the same order, so edge connectivity is preserved.

**(2) PMF computation.** The `compute_pmf` method (lines 309--361) calls `build(theta)` to obtain $G(\boldsymbol{\theta})$, then calls `g.pdf(t_i, granularity)` for each time point (continuous case) or `g.dph_pmf(t_i)` for the discrete case. These are the same C++ methods that implement @alg-14-cph-uniformization (CPH) and @alg-14-dph-forward-step (DPH) from [14].

**(3) Moment computation.** The `compute_moments` method (lines 271--307) calls `build(theta)` and then `compute_moments_impl`, which iteratively calls `g.expected_waiting_time(rewards)` (the C++ implementation of @alg-15-sojourn-cross-moments from [15]) and applies the factorial scaling $\mathbb{E}[\tau^k] = k! \cdot r_0^{(k)}$ where $r_0^{(k)}$ is the $k$-th iterated expected waiting time at the starting vertex. $\square$
:::

## Algorithms {.unnumbered}

::: {#alg-26-graph-builder-pmf}

#### GraphBuilder PMF Computation

*Description.* This algorithm describes the full pipeline from a serialized graph structure and parameter vector to PDF/PMF values. It is the C++ implementation behind the JAX FFI handler (@alg-25-jax-pmf). The key optimization is that JSON parsing and structure caching happen once (at `GraphBuilder` construction), while graph instantiation and forward algorithm execution happen per evaluation. For batch processing, the GraphBuilder is reused across all parameter vectors in the batch.

```
GraphBuilder PMF Computation
1: Let J be a JSON string encoding the graph structure (Definition 26.1)
2: Let theta = (theta_1, ..., theta_d) be the parameter vector
3: Let t = (t_1, ..., t_n) be evaluation points
4: Let discrete be a boolean flag and g the granularity
5:
6: ▷ One-time initialization
7: function GraphBuilder(J)
8:   Parse J into:
9:     states[0..p-1], edges, start_edges,
10:    param_edges, start_param_edges,
11:    d ← param_length, l ← state_length, p ← n_vertices,
12:    mode ← weight_mode
13:  Store all parsed data in builder B
14:  return B
15: end function
16:
17: ▷ Compute edge weight from coefficients and parameters
18: function ComputeWeight(c, theta, mode)
19:   if mode = "linear" then
20:     return Sigma_{k=1}^{d} c_k * theta_k
21:   else if mode = "log" then
22:     s ← 0
23:     for k ← 1 to d do
24:       if c_k * theta_k <= 0 then error("non-positive product")
25:       s ← s + log(c_k * theta_k)
26:     end for
27:     return exp(s)
28:   end if
29: end function
30:
31: ▷ Build concrete graph from cached structure
32: function Build(B, theta)
33:   if length(theta) != d then error("length mismatch")
34:   G ← new Graph(l)
35:   v_start ← G.starting_vertex()
36:   vertices ← empty array of length p
37:   for i ← 0 to p-1 do
38:     if states[i] = (0,...,0) and i = 0 then
39:       vertices[i] ← v_start               ▷ Reuse starting vertex
40:     else
41:       vertices[i] ← G.create_vertex(states[i])
42:     end if
43:   end for
44:   for (from, to, w) in edges do
45:     vertices[from].add_edge(vertices[to], w)
46:   end for
47:   for (to, w) in start_edges do
48:     v_start.add_edge(vertices[to], w)
49:   end for
50:   for (from, to, c_1, ..., c_d) in param_edges do
51:     w ← ComputeWeight((c_1,...,c_d), theta, mode)
52:     vertices[from].add_edge(vertices[to], w)
53:   end for
54:   for (to, c_1, ..., c_d) in start_param_edges do
55:     w ← ComputeWeight((c_1,...,c_d), theta, mode)
56:     v_start.add_edge(vertices[to], w)
57:   end for
58:   return G
59: end function
60:
61: ▷ Compute PMF/PDF values
62: function ComputePMF(B, theta, t, discrete, g)
63:   G ← Build(B, theta)
64:   y ← empty array of length n
65:   for i ← 1 to n do
66:     if discrete then
67:       y_i ← G.dph_pmf(floor(t_i))        ▷ DPH Forward Step
68:     else
69:       y_i ← G.pdf(t_i, g)                ▷ CPH Uniformization
70:     end if
71:   end for
72:   return y
73: end function
74:
75: ▷ Compute moments
76: function ComputeMoments(B, theta, K)
77:   G ← Build(B, theta)
78:   rewards ← empty                         ▷ Standard moments (no rewards)
79:   moments ← empty array of length K
80:   r ← G.expected_waiting_time(rewards)    ▷ Sojourn Cross-Moments
81:   moments[1] ← r[0]                       ▷ E[tau]
82:   for k ← 2 to K do
83:     r ← G.expected_waiting_time(r)        ▷ Iterate with previous result
84:     moments[k] ← k! * r[0]               ▷ E[tau^k] = k! * iterated result
85:   end for
86:   return moments
87: end function
```

**Correspondence table:**

| Pseudocode variable | Math symbol | Code variable (file:function) |
|---------------------|-------------|-------------------------------|
| `J` | $J$ (serialized structure) | `structure_json` (`graph_builder.cpp:GraphBuilder`) |
| `B` | $\mathcal{B}$ (builder object) | `this` (`graph_builder.cpp`) |
| `theta` | $\boldsymbol{\theta}$ | `theta` / `theta_vec` (`graph_builder.cpp:build`) |
| `t` | $\mathbf{t}$ | `times` / `times_vec` (`graph_builder.cpp:compute_pmf`) |
| `d` | $d$ (parameter dimension) | `param_length_` (`graph_builder.hpp`) |
| `l` | $\ell$ (state dimension) | `state_length_` (`graph_builder.hpp`) |
| `p` | $p$ (number of vertices) | `n_vertices_` (`graph_builder.hpp`) |
| `mode` | weight mode | `weight_mode_` (`graph_builder.hpp`) |
| `states` | --- | `states_` (`graph_builder.hpp`) |
| `edges` | --- | `edges_` (`graph_builder.hpp`) |
| `param_edges` | --- | `param_edges_` (`graph_builder.hpp`) |
| `c` | $\mathbf{c}$ (coefficient vector) | `coefficients` (`graph_builder.hpp:ParameterizedEdge`) |
| `G` | $G(\boldsymbol{\theta})$ | `g` (`graph_builder.cpp:build`) |
| `y` | $\mathbf{y} = h(\boldsymbol{\theta}, \mathbf{t})$ | `result_vec` (`graph_builder.cpp:compute_pmf`) |
| `moments` | $(\mathbb{E}[\tau], \ldots, \mathbb{E}[\tau^K])$ | `result` (`graph_builder.cpp:compute_moments_impl`) |
| `r` | iterated expected waiting time | `rewards2` (`graph_builder.cpp:compute_moments_impl`) |
| `K` | number of moments | `nr_moments` (`graph_builder.cpp:compute_moments`) |

**Complexity.** Let $p = |V|$ (vertices), $m = |E|$ (edges), $d = |\boldsymbol{\theta}|$ (parameters), $n = |\mathbf{t}|$ (evaluation points), $K_s$ the number of uniformization steps, and $|J|$ the JSON string length.

- **GraphBuilder construction:** Time $O(|J|)$ for JSON parsing. Space $O(p \cdot \ell + m \cdot d)$ for cached structure.
- **Build:** Time $O(p + m)$ for vertex creation and edge addition. Space $O(p + m)$ for the concrete graph.
- **ComputePMF:** Time $O(p + m + n \cdot K_s \cdot m)$. The $O(p + m)$ is for `build`; the $O(n \cdot K_s \cdot m)$ is for $n$ forward algorithm runs.
- **ComputeMoments:** Time $O(p + m + K \cdot p^2)$. The $O(K \cdot p^2)$ is for $K$ applications of `expected_waiting_time`, each involving graph elimination on $p$ vertices.
- **Batch evaluation ($B$ parameter vectors):** Time $O(B \cdot (p + m + n \cdot K_s \cdot m))$ with parallelism across $B$ via OpenMP.

**Correctness.** Follows from @thm-26-equivalence. The `Build` function reconstructs $G(\boldsymbol{\theta})$ from the cached structure with weights evaluated at $\boldsymbol{\theta}$. The `ComputePMF` and `ComputeMoments` functions invoke the same C++ implementations used by @alg-14-dph-forward-step and @alg-14-cph-uniformization ([14]) and @alg-15-sojourn-cross-moments ([15]).
:::

## Implementation Notes {.unnumbered}

**Source code mapping:**

| Algorithm | File | Function | Lines |
|-----------|------|----------|-------|
| @alg-26-graph-builder-pmf (constructor) | `src/cpp/parameterized/graph_builder.cpp` | `GraphBuilder::parse_structure` | L17--L116 |
| @alg-26-graph-builder-pmf (weight) | `src/cpp/parameterized/graph_builder.cpp` | `GraphBuilder::compute_weight` | L196--L218 |
| @alg-26-graph-builder-pmf (build) | `src/cpp/parameterized/graph_builder.cpp` | `GraphBuilder::build` | L118--L194 |
| @alg-26-graph-builder-pmf (PMF) | `src/cpp/parameterized/graph_builder.cpp` | `GraphBuilder::compute_pmf` | L309--L361 |
| @alg-26-graph-builder-pmf (moments) | `src/cpp/parameterized/graph_builder.cpp` | `GraphBuilder::compute_moments_impl` | L228--L269 |
| @alg-26-graph-builder-pmf (PMF+moments) | `src/cpp/parameterized/graph_builder.cpp` | `GraphBuilder::compute_pmf_and_moments` | L363--L574 |

**Deviations from pseudocode:**

- The `compute_pmf_and_moments` method in the implementation combines PMF and moment computation in a single call, building the graph only once. The pseudocode presents them as separate functions for clarity.
- The implementation supports 2D reward matrices for multivariate models (@def-01-mph), computing PDF and moments per feature dimension by iterating over reward columns and applying `reward_transform` to the base graph. This is not shown in the pseudocode.
- The `compute_pmf_multivariate` method handles sparse observations (zero entries = no observation) by skipping the forward algorithm for those entries.
- The GIL release/acquire pattern (`py::gil_scoped_release`) is a pybind11 implementation detail not reflected in the pseudocode. It copies data from NumPy arrays to C++ vectors before releasing the GIL, then copies results back to NumPy arrays after reacquiring it.

## Symbol Index {.unnumbered}

| Symbol | Name | First appearance |
|--------|------|-----------------|
| $J$ | Serialized graph structure (JSON) | @def-26-serialization |
| $\mathcal{B}$ | GraphBuilder object | @def-26-graph-builder |
| $G(\boldsymbol{\theta})$ | Concrete graph at parameters $\boldsymbol{\theta}$ | @def-26-graph-builder |
| $W_{\boldsymbol{\theta}}$ | Weight function evaluated at $\boldsymbol{\theta}$ | @def-26-graph-builder |
| $\boldsymbol{\Theta}$ | Parameter matrix ($B \times d$) | @def-26-batch-eval |
| $B$ | Batch size | @def-26-batch-eval |
| $\ell$ | State vector dimension | @def-26-serialization |
