Freezer#
What’s in this notebook? This notebook demonstrates the module
freezer— a framework for freezing out heavy moduli from the EFT. TheConifoldFreezerintegrates out the conifold modulus \(z_{\text{cf}}\) by solving its leading-order EOM analytically.In this notebook, you will learn:
How to set up a
ConifoldFreezerfrom an existingFluxEFTor ratherFluxVacuaFindermodelHow the conifold modulus is solved analytically and frozen out
How to compute the reduced F-terms \(D_\alpha W_{\text{bulk}}\) in the light-field EFT
How to write a custom
Freezersubclass for other moduliPrerequisites: NB10: coniLCS pipeline for conifold geometry setup.
We use the \(h^{1,1}=99\), \(h^{1,2}=3\) example from 2009.03312 throughout.
(Created: March 2026)
Outline#
Setup#
# General imports
import warnings
import numpy as np
from scipy.optimize import root
# JAX imports
import jax
import jax.numpy as jnp
jax.config.update("jax_enable_x64", True)
# JAXVacua
import jaxvacua as jvc
from jaxvacua.flux_utils import flux_to_pfv, pfv_to_flux, pfv_to_moduli
from jaxvacua.freezer import Freezer, ConifoldFreezer
warnings.filterwarnings('ignore')
Model setup#
We use the \(h^{1,1}=99\), \(h^{1,2}=3\) coniLCS model from 2009.03312, with the conifold curve \([-1,1,0]\).
from cytools import Polytope
poly = Polytope([[-1,3,-2,-1],[1,-1,0,0],[-1,0,0,1],[-1,0,0,0],[-1,0,1,1],[-1,0,2,0],[-1,0,1,0]])
cy = poly.triangulate().get_cy()
basis_matrix = np.array([[0, 1, 1], [1, 1, 0], [0, 0, 1]])
conifold_curve = np.array([-1, 1, 0])
model = jvc.FluxEFT(
h12=cy.h11(), Q=cy.h11()+cy.h12()+2,
use_cytools=True, mirror_cy=cy, ncf=2, use_gvs=True,
maximum_degree=6, basis_change=basis_matrix, conifold_curve=conifold_curve,
limit="coniLCS", prange=10, conifold_basis=True
)
model
freezer — freezing out heavy moduli#
The idea#
When a modulus is parametrically heavy, its leading-order EOM determines it as a function of the remaining light fields. We can substitute this solution back into the superpotential and work with an EFT that has fewer degrees of freedom.
The Freezer base class defines the general interface. Subclasses implement:
heavy_indices— which moduli to freeze outsolve_heavy— how to solve for them given light fields_real_light_to_full— how to convert real coordinates
The base class then provides reconstruct_full_moduli, superpotential, DW_light, DW_x_light, and dDW_x_light for free.
ConifoldFreezer: freezing out \(z_{\text{cf}}\)#
Near the conifold locus, \(z_{\text{cf}}\) acquires a parametrically large mass. Its leading-order EOM gives
Documentation note. The setup cell only needs the leading formula. The appendix below fixes the notation used by
conifold.zcf_solver, including the logarithmic prefactor,W_log_coeff, and the Kähler-covariant correction. Future notation changes should update the notebook and solver documentation together.
The ConifoldFreezer implements this formula. Let’s set it up:
freezer = ConifoldFreezer(model, conifold_index=0)
print(f"Heavy indices: {freezer.heavy_indices}")
print(f"Light indices: {freezer.light_indices}")
print(f"Freezing out {freezer.n_heavy} modulus, keeping {freezer.n_light} light moduli + tau")
print(f"Conifold degree (sourced from lcs_tree.conifold.ncf): {freezer.ncf}")
Solving for \(z_{\text{cf}}\) from the bulk moduli#
Given the bulk moduli and \(\tau\), the freezer solves for \(z_{\text{cf}}\). The
available mode values mirror the underlying compute_zcf dispatcher:
mode="manual"is the full closed-form coni-LCS expression. It uses the actual bulk moduli supplied to the freezer and assembles the logarithmic coefficient from the intersection data, \(a\)-matrix, \(b\)-vector, and included worldsheet-instanton terms.mode="pfv"is the perturbatively flat-vacuum / linear-racetrack approximation. It replaces the bulk equations by the analytic PFV racetrack relation expressed directly in the integer flux data.
The ConifoldFreezer is the reduced-EFT wrapper. The direct
model.compute_zcf_x(...) calls below are the lower-level dispatcher used by
older notebooks and by the freezer internally. They should agree mode by mode;
the difference between the full/manual and PFV numbers is a physics
approximation, not a wrapper change.
# Use the first solution from Table 1 of arXiv:2009.03312 as the running example.
M = np.array([4, -8, 8])
K = np.array([-8, 3, -6])
tau0 = 1j / 0.38
# PFV initial guess for the bulk moduli.
z0 = pfv_to_moduli(model, M, K, tau0)
zbulk = z0[1:] # light (bulk) moduli
flux = pfv_to_flux(model, M, K)
# Full/manual route: closed-form coni-LCS expression evaluated at zbulk.
zcf_full = freezer.solve_heavy(zbulk, tau0, flux, mode="manual")
# PFV route: linear-racetrack approximation evaluated from the flux data.
zcf_pfv = freezer.solve_heavy(zbulk, tau0, flux, mode="pfv")
# Cross-check directly against the model's `compute_zcf_x` dispatcher.
# `compute_zcf_x` takes the bulk-only real vector (length 2*h12, no z_cf
# direction); slicing `x[2:]` strips Re(z_cf) / Im(z_cf) from the full real x.
x_full = model._convert_complex_to_real(z0, jnp.conj(z0), tau0, jnp.conj(tau0))
x_bulk = x_full[2:]
zcf_old_pfv = model.compute_zcf_x(x_bulk, flux, mode="pfv")
zcf_old_full = model.compute_zcf_x(x_bulk, flux, mode="manual")
print("Full/manual coni-LCS solve")
print(" ConifoldFreezer: ", np.abs(zcf_full[0]))
print(" model.compute_zcf_x: ", np.abs(zcf_old_full))
print()
print("PFV / linear-racetrack approximation")
print(" ConifoldFreezer: ", np.abs(zcf_pfv[0]))
print(" model.compute_zcf_x: ", np.abs(zcf_old_pfv))
Reconstructing full moduli and computing the superpotential#
The freezer can reconstruct the full moduli vector (inserting the solved \(z_{\text{cf}}\)) and evaluate EFT quantities on the reduced field space:
# Reconstruct full moduli from light ones
z_full = freezer.reconstruct_full_moduli(zbulk, tau0, flux)
print(f"Full moduli (freezer): {z_full}")
print(f"Full moduli (PFV): {z0}")
print()
# Superpotential evaluated with z_cf on-shell
W_reduced = freezer.superpotential(zbulk, tau0, flux)
W_full = model.superpotential(z0, tau0, flux)
print(f"|W| (reduced EFT): {np.abs(W_reduced):.6e}")
print(f"|W| (full PFV): {np.abs(W_full):.6e}")
Using the freezer for optimisation#
The key use case: solve for the true vacuum using only the bulk moduli as free variables. We first find the full vacuum, then show the freezer reproduces \(z_{\text{cf}}\) at the solution.
# Find the true vacuum using the full model (all moduli free)
x0 = model._convert_complex_to_real(z0, jnp.conj(z0), tau0, jnp.conj(tau0))
res = root(model.DW_x, x0, args=(flux,), jac=model.dDW_x, tol=1e-10, method="hybr")
x_sol = res.x
z_sol, _, tau_sol, _ = model._convert_real_to_complex(x_sol)
print(f"Full minimisation converged: {res.success}")
print(f"sum|DW|: {np.sum(np.abs(model.DW_x(x_sol, flux))):.2e}")
print(f"z_cf (numerical): {z_sol[0]}")
print(f"|z_cf| (numerical): {np.abs(z_sol[0]):.6e}")
Now we use the freezer to predict \(z_{\text{cf}}\) from the bulk moduli at the solution and compare with the numerics:
# Use the freezer to predict z_cf from the solved bulk moduli
zbulk_sol = z_sol[1:]
zcf_frozen = freezer.solve_heavy(zbulk_sol, tau_sol, flux)
print(f"|z_cf| (numerical): {np.abs(z_sol[0]):.10e}")
print(f"|z_cf| (freezer, full): {np.abs(zcf_frozen[0]):.10e}")
print(f"|z_cf| (freezer, PFV): {np.abs(freezer.solve_heavy(zbulk_sol, tau_sol, flux, mode='pfv')[0]):.10e}")
print()
print(f"Relative error (full): {np.abs(np.abs(z_sol[0]) - np.abs(zcf_frozen[0])) / np.abs(z_sol[0]):.2e}")
Reduced DW: F-terms for the light moduli only#
The reduced F-terms are the light-modulus components of the full covariant
F-term vector after substituting the on-shell heavy modulus. To make the
comparison honest, the code below evaluates both the full and reduced quantities
at the same frozen point z_frozen_sol, rather than mixing the numerical full
root with a separately reconstructed analytic \(z_{\text{cf}}\).
# Full DW at the numerical solution, shown as a reference.
DW_full_numeric = model.DW(z_sol, jnp.conj(z_sol), tau_sol, jnp.conj(tau_sol), flux)
# Reconstruct the frozen full point from the solved light fields and evaluate DW there.
z_frozen_sol = freezer.reconstruct_full_moduli(zbulk_sol, tau_sol, flux, mode="manual")
DW_full_frozen = model.DW(z_frozen_sol, jnp.conj(z_frozen_sol), tau_sol, jnp.conj(tau_sol), flux)
# Reduced DW: keep only the light-modulus components and D_tau W.
light_idx = jnp.array(freezer.light_indices)
DW_light = jnp.append(DW_full_frozen[light_idx], DW_full_frozen[-1])
print("Full DW at the numerical solution (all moduli + tau):")
print(f" |DW| = {np.abs(DW_full_numeric)}")
print()
print("Full DW at the frozen point (all moduli + tau):")
print(f" |DW| = {np.abs(DW_full_frozen)}")
print()
print("Reduced DW at the same frozen point (light moduli + tau):")
print(f" |DW_light| = {np.abs(DW_light)}")
print(f" Dimensions: {len(DW_full_frozen)} (full) -> {len(DW_light)} (reduced)")
# Quantify why the frozen-point residual is larger than at the full numerical root.
delta_zcf = complex(z_frozen_sol[0] - z_sol[0])
delta_z = np.asarray(z_frozen_sol - z_sol)
delta_DW = np.asarray(DW_full_frozen - DW_full_numeric)
print("Frozen point versus full numerical root:")
print(f" |Δz_cf| = {abs(delta_zcf):.3e}")
print(f" max |Δz| = {float(np.max(np.abs(delta_z))):.3e}")
print(f" max |ΔDW| = {float(np.max(np.abs(delta_DW))):.3e}")
print(f" max |DW| frozen = {float(jnp.max(jnp.abs(DW_full_frozen))):.3e}")
print(f" max |DW| numerical = {float(jnp.max(jnp.abs(DW_full_numeric))):.3e}")
The reduced system is evaluated at the analytic frozen point, not at the fully numerical root. The analytic solution for \(z_{
m cf}\) is a controlled local approximation, so it shifts the full point by a tiny but non-zero amount. Since the exact numerical root has \(D_IW\) tuned to machine precision, even a very small displacement can lift some light-field F-terms from around \(10^{-15}\) to around \(10^{-8}\). The relevant consistency check is therefore the agreement between DW_x_light and the corresponding light slice of the full DW_x at the same frozen point, not equality with the separate full numerical root.
Real-coordinate interface: DW_x_light and dDW_x_light#
For use with scipy.optimize.root or similar solvers, the freezer also provides
real-coordinate versions. These operate on the vector of light fields only, but
internally reconstruct the full real vector by solving for \(z_{\text{cf}}\).
The comparison must again be made at the same reconstructed full point. The
entries need not match the light slice of model.DW_x(x_sol, flux) if x_sol
uses the numerical full root and DW_x_light uses the analytic frozen root.
# Reduced real DW: only bulk moduli + tau (6 components instead of 8).
x_bulk = x_sol[2:] # drop Re(z_cf), Im(z_cf)
DW_x_light = freezer.DW_x_light(x_bulk, flux, mode="manual")
# Full real DW evaluated at the same frozen point used by DW_x_light.
x_frozen = freezer._real_light_to_full(x_bulk, flux, mode="manual")
DW_x_full_frozen = model.DW_x(x_frozen, flux)
DW_x_expected = DW_x_full_frozen[freezer._real_light_slice]
# Full numerical root, shown only to indicate the nearby-but-different point.
DW_x_full_numeric = model.DW_x(x_sol, flux)
print(f"Full DW_x at numerical solution ({len(DW_x_full_numeric)} components):")
print(f" |DW_x| = {np.abs(DW_x_full_numeric)}")
print()
print(f"Reduced DW_x_light at frozen point ({len(DW_x_light)} components):")
print(f" |DW_x_light| = {np.abs(DW_x_light)}")
print(f" max |DW_x_light - full frozen light slice| = {float(jnp.max(jnp.abs(DW_x_light - DW_x_expected))):.2e}")
Solving the reduced F-term conditions with scipy#
We can directly use DW_x_light and dDW_x_light as objective and Jacobian for scipy.optimize.root, solving only for the 6 bulk + tau real variables instead of 8:
# Initial guess: PFV values for bulk moduli + tau (drop z_cf)
x0_bulk = model._convert_complex_to_real(z0, jnp.conj(z0), tau0, jnp.conj(tau0))[2:]
# Solve the reduced system
res_reduced = root(
lambda x: freezer.DW_x_light(x, flux),
x0_bulk,
jac=lambda x: freezer.dDW_x_light(x, flux),
tol=1e-10,
method="hybr"
)
print(f"Reduced minimisation converged: {res_reduced.success}")
print(f"Variables: {len(x0_bulk)} (reduced) vs {len(x0)} (full)")
print(f"sum|DW_x|: {np.sum(np.abs(res_reduced.fun)):.2e}")
print(f"nfev: {res_reduced.nfev}")
Compare the solution from the reduced system with the full minimisation:
# Recover full moduli from the reduced solution
x_red = res_reduced.x
x_full_reconstructed = np.array(freezer._real_light_to_full(x_red, flux))
z_red, _, tau_red, _ = model._convert_real_to_complex(x_full_reconstructed)
print("Bulk moduli comparison (full vs reduced):")
for i in range(1, model.h12):
print(f" z[{i}]: {z_sol[i]:.10f} vs {z_red[i]:.10f}")
print(f"\ntau: {tau_sol:.10f} vs {tau_red:.10f}")
print(f"\n|z_cf| (full): {np.abs(z_sol[0]):.10e}")
print(f"|z_cf| (reduced): {np.abs(z_red[0]):.10e}")
# Check W0
W_red = model.superpotential(z_red, tau_red, flux, normalise=True)
W_full = model.superpotential(z_sol, tau_sol, flux, normalise=True)
print(f"\n|W0| (full): {np.abs(W_full):.10e}")
print(f"|W0| (reduced): {np.abs(W_red):.10e}")
Validation across multiple flux choices#
Let’s verify the freezer against all five examples from Table 1 in 2009.03312:
Mlist = np.array([[4,-8,8], [4,-8,10], [8,-12,6], [-8,4,12], [-14,6,27]])
Klist = np.array([[-8,3,-6], [-6,3,-4], [-5,1,-2], [5,1,-4], [4,1,-2]])
gslist = np.array([0.38, 0.15, 0.125, 0.35, 0.0643])
print(f"{'#':>2} | {'converged':>9} | {'|z_cf| num':>14} | {'|z_cf| frozen':>14} | {'rel err':>10} | {'|W0|':>12}")
print("-" * 85)
# Construct FVF model
model_fvf = jvc.FluxVacuaFinder.from_model(model=model)
for i in range(len(Mlist)):
Mi, Ki, gsi = Mlist[i], Klist[i], gslist[i]
# Build flux and initial guess via flux_utils
fluxi = model.pfv_to_flux(Mi, Ki)
tau_i = 1j / gsi
z_i = model.pfv_to_moduli(Mi, Ki, tau_i)
# Full minimisation
x_i = model._convert_complex_to_real(z_i, jnp.conj(z_i), tau_i, jnp.conj(tau_i))
res_i = root(model.DW_x, x_i, args=(fluxi,), jac=model.dDW_x, tol=1e-10, method="hybr")
if not res_i.success:
# Fallback: Newton + retry
z_tmp, _, tau_tmp, _ = model._convert_real_to_complex(res_i.x)
z_tmp, tau_tmp, _ = model_fvf.newton_method_flux_vacua(
z_tmp, tau_tmp, fluxi, step_size_Newton=0.1, tol=1e-12, max_iters=100, mode="SUSY", solver_mode="real"
)
x_i = model._convert_complex_to_real(z_tmp, jnp.conj(z_tmp), tau_tmp, jnp.conj(tau_tmp))
res_i = root(model.DW_x, x_i, args=(fluxi,), jac=model.dDW_x, tol=1e-8, method="hybr")
z_i_sol, _, tau_i_sol, _ = model._convert_real_to_complex(res_i.x)
# Freezer prediction
zcf_frozen_i = freezer.solve_heavy(z_i_sol[1:], tau_i_sol, fluxi)
zcf_num = np.abs(z_i_sol[0])
zcf_frz = np.abs(zcf_frozen_i[0])
rel_err = np.abs(zcf_num - zcf_frz) / zcf_num
W0 = np.abs(model.superpotential(z_i_sol, tau_i_sol, fluxi, normalise=True))
print(f"{i:>2} | {str(res_i.success):>9} | {zcf_num:>14.6e} | {zcf_frz:>14.6e} | {rel_err:>10.2e} | {W0:>12.6e}")
Writing a custom Freezer subclass#
The Freezer base class is designed to be extended. To freeze out a different set of heavy moduli, subclass Freezer and implement heavy_indices, solve_heavy, and _real_light_to_full. Here is a minimal skeleton:
from jaxvacua.freezer import Freezer
class MyCustomFreezer(Freezer):
@property
def heavy_indices(self):
return (0, 1) # freeze out the first two moduli
def solve_heavy(self, z_light, tau, fluxes, **kwargs):
# Your analytic (or numerical) solution for the heavy moduli
# as functions of z_light, tau, and fluxes
z_heavy = ...
return z_heavy
def _real_light_to_full(self, x_light, fluxes, **kwargs):
# Convert real light coordinates to full real array
# by solving for heavy moduli and inserting them
...
return x_full
The base class then automatically provides superpotential, DW_light, DW_x_light, and dDW_x_light for the reduced theory.
Theory: \(z_{\rm cf}\) EOM and the \(\widetilde W_1\) corrections#
This appendix documents the analytic solution that ConifoldFreezer.solve_heavy implements internally. The conventions follow the conifold-PFV setup used in arXiv:2009.03312 and arXiv:2004.10740.
Leading-order conifold \(F\)-term#
Near \(z_{\rm cf} = 0\), the conifold-modulus derivative of the GVW superpotential takes the local form
where the logarithm originates from the conifold-period monodromy and \(\widetilde W_1\) packages all non-singular dependence on the bulk moduli, the axio-dilaton, and the flux quanta. In the code the two ingredients are accessible separately as
Quantity in code |
Math symbol |
Method |
|---|---|---|
|
\((M^1 - \tau H^1)\,n_{\rm cf}/(2\pi i)\) |
conifold/zcf_solver |
|
\(\widetilde W_1\) |
conifold/zcf_solver |
W_log_coeff has three implementation modes: "manual" (closed-form Li sums), "autodiff" (F_coniLCS_exp derivative), and "pfv" (PFV/linear-racetrack approximation).
Leading-order solution for \(z_{\rm cf}\)#
Dropping the Kähler-covariant term and equating the leading singular + constant pieces, the EOM \(\partial_{z_{\rm cf}} W \simeq 0\) has the closed-form solution
The logarithmic monodromy of the conifold period thus drives the vacuum exponentially close to \(z_{\rm cf} = 0\). The role of \(\widetilde W_1\) is only to set the exponent.
Kähler-covariant correction \(\delta\widetilde W_1\)#
Because \(\partial_{z_{\rm cf}} K\) is generically finite (not zero) at \(z_{\rm cf} = 0\), the full \(F\)-term is
where
The corrected solution then reads
In code: the additive piece \(\delta\widetilde W_1\) is exposed as log_coeff_K_corr(z, cz, tau, ctau, flux, conj) and is included by passing apply_correction=True to compute_zcf* and solve_heavy. For coni-PFVs with tuned \(W \ll 1\), \(\delta\widetilde W_1\) is parametrically suppressed — the correction is small but non-zero. For generic AFVs with \(W \sim \mathcal O(1)\), \(\delta\widetilde W_1\) should not be neglected.
What freezer.solve_heavy(mode=...) does internally#
z_bulk, tau, fluxes
│
▼
log_prefactor(tau, flux) ← conifold/zcf_solver
│
│
▼
W_log_coeff(z_bulk, tau, flux, mode=...) ← 3 modes available
│ • "manual" — closed-form Li sums
│ • "autodiff" — F_coniLCS_exp gradient
│ • "pfv" — racetrack approximation
│
▼
[if apply_correction]:
W_log_coeff += log_coeff_K_corr(z, cz, tau, ctau, flux)
│
▼
_zcf_from_log_coeff(W_log_coeff, tau, flux)
│
│ z_cf = -1/(2πi) · exp(-W_log_coeff / log_prefactor)
▼
return z_cf
All three mode flavours give the same \(z_{\rm cf}\) to machine precision (manual ≡ autodiff are exact reformulations; "pfv" differs by deliberate approximations and is the cheapest of the three).
Take-aways#
The
Freezerframework provides a uniform interface for integrating out heavy moduli analytically.ConifoldFreezeris the canonical implementation; the base class is designed for subclassing.The conifold modulus EOM reduces to a closed form: \(z_{\rm cf} = -(2\pi i)^{-1} \exp\!\left(-2\pi i\,\widetilde W_1 / [(M^1 - \tau H^1) n_{\rm cf}]\right)\). The logarithmic monodromy of the conifold period is what makes \(z_{\rm cf}\) exponentially small in \(\widetilde W_1\).
\(\widetilde W_1\) packages the non-singular flux/moduli dependence. The Kähler-covariant correction \(\delta\widetilde W_1 = (\partial_{z_{\rm cf}} K) W_{\rm bulk}\) is small when \(W\) is tuned (coni-PFVs) and \(\mathcal O(1)\) otherwise.
The
Freezer.DW_x_light/dDW_x_lightinterface lets you solve for SUSY vacua in the reduced bulk EFT (one fewer modulus). Newton refinement converges in fewer iterations because \(z_{\rm cf}\) is already at its true value.solve_heavy(mode="manual" | "autodiff" | "pfv")controls the implementation of \(\widetilde W_1\); the first two agree to machine precision, the third is a deliberately cheaper approximation.
Further reading#
NB09 — Moduli-space limits (bulk + series approximations)
NB10 — coni-LCS pipeline (uses ConifoldFreezer for the conifold PFV workflow)
arXiv:2009.03312, arXiv:2004.10740 — conifold-PFV mechanism