torchcell.metabolism.flux_layer.FluxLayer
- class FluxLayer(gem: GemTensors, model_gene_ids: list[str], config: FluxLayerConfig | None = None, compartments: CompartmentParameters | None = None, kcat_per_s: Tensor | None = None, molecular_weight_kda: Tensor | None = None, null_space: Tensor | None = None, constitutive_genes: list[str] | None = None)[source]
Bases:
ModuleGene tokens in, a feasible flux vector and its feasibility residuals out.
The chain, and every arrow in it is differentiable:
\[\gamma_g \longrightarrow c_u \longrightarrow c_j \longrightarrow [\,v^\ell_j,\ v^u_j\,] \longrightarrow v_j \longrightarrow \{C_{\mathrm{bal}},C_{\mathrm{th}},C_{\mathrm{cap}}, C_{\mathrm{bud}},C_{\mathrm{diss}}\}\]A deletion sets \(\gamma_g=0\) hard, from the perturbation rather than from a learned gate, because whether a deleted gene is absent is not something the model should be free to disagree with. A complex takes a softmin over its subunits (the scarcest limits); isozymes sum (capacities add). A reaction whose availability falls to zero has \(v^\ell_j=v^u_j=0\) and therefore carries exactly zero flux – the deletion bites through the parameterization, not through a penalty that could be traded off against the data term.
- gene_availability(h_genes: Tensor, perturbation_indices: Tensor, perturbation_indices_batch: Tensor) Tensor[source]
Per-sample \(\gamma_g\in[0,1]\) over the GEM’s genes.
[B, n_gem_genes].Deleted genes are set to zero hard, from the perturbation. Everything else is a learned sigmoid gate, which is what leaves room for dosage, hypomorphic alleles and over-expression later without changing this signature.
Constitutive genes are pinned to one. A heterologous cassette gene has no gene token, so the token-universe mask would otherwise zero it, and zeroing it switches off the pathway it was added to provide in every sample.
- reaction_availability(gamma: Tensor) Tensor[source]
Fold \(\gamma_g\) up the GPR: softmin over complexes, sum over isozymes.
\[c_u=-\tfrac1\beta\log\!\sum_{g\in C_u}e^{-\beta\gamma_g},\qquad c_j=\min\Big(1,\ \sum_{u:\,\rho(u)=j}c_u\Big)\]Reactions with no GPR keep availability 1: an unannotated reaction is not one that no gene catalyzes, it is one nobody has assigned a gene to, and setting it to zero would let a missing annotation delete a reaction.
- dynamic_box(c_j: Tensor) tuple[Tensor, Tensor][source]
The genotype-dependent flux box \([v^\ell_j,v^u_j]\).
Availability scales the box multiplicatively, so \(c_j=0\) collapses it to the single point \(0\). Enzyme capacity enters here too rather than as a penalty:
\[\bar v^u_j=\min\Big(v^u_j,\ c_j\sum_{u:\rho(u)=j}k_{\mathrm{cat},u}\bar E\Big)\]Putting capacity in the box rather than the objective is what makes it exact at no cost, and it is why there is no
C_boxterm in the loss.
- reaction_potential(context: Tensor) tuple[Tensor, Tensor, dict[str, Tensor]][source]
Per-reaction \(\Delta_r G_j\) and the mask of reactions it is valid on.
In
ANCHOREDmode this is Thermo-Flux Eq. (4) term by term. InFREEmode it is \(\sum_i S_{ij}\mu_i\) with \(\mu\) unconstrained. InOFFmode the mask is empty and the caller skips the term.
- turnover(v: Tensor) Tensor[source]
\(\omega_i(v)=\tfrac12\sum_j|S_{ij}v_j|\), the throughput of metabolite i.
Doubles as the mass-balance normalizer and as the precursor-pool readout. A residual of 0.01 is negligible on a metabolite carrying flux 10 and nonsense on one carrying 0.01, which is why the balance penalty is a ratio rather than a square.
- forward(h_genes: Tensor, context: Tensor, perturbation_indices: Tensor, perturbation_indices_batch: Tensor) dict[str, Tensor][source]
Predict a flux vector and every feasibility residual for it.
- Parameters:
h_genes –
[B, N, d]perturbed gene token representations.context –
[B, d]whole-cell context (the perturbed-gene pool).perturbation_indices – Flat
[total_pert]gene-token indices.perturbation_indices_batch –
[total_pert]sample assignment.
- Returns:
A dict with
v[B, r], every constraint residual as a scalar, and the per-sample feasibility diagnostics. Residuals are returned rather than summed so the caller owns the weighting and every term can be logged separately.
- constraint_loss(out: dict[str, Tensor]) Tensor[source]
Weighted sum of the physics terms. Every term is already dimensionless.
Normalization to dimensionless happens inside
forward()rather than here, because a term whose scale depends on the phenotype’s units cannot be tuned against another one: that is the failure the 019 joint runs hit, where an un-normalized morphology MSE swamped expression.
- coverage_report() dict[str, Any][source]
What fraction of the layer rests on data rather than on a default.
This is not optional reporting. A capacity constraint built on a default
kcatfor every reaction is a uniform rescaling of the box, not an enzyme constraint, and the two are indistinguishable from the loss curve alone.