coni-LCS limit and PFV pipeline#
What’s in this notebook? This notebook stitches together the two main pipelines used near a conifold singularity: (i) the coni-LCS prepotential and its
coniLCS_seriesTaylor variant, validated against worked examples from arXiv:2009.03312, and (ii) the perturbatively-flat-vacuum (PFV) construction, both at standard LCS (arXiv:1903.00596) and in its conifold-extended form (arXiv:2004.10740).In this notebook, you will learn:
How
FluxVacuaFinder(..., limit="coniLCS")constructs the conifold-aware prepotential and whatbasis_change,conifold_curve, andncfmean in practice.How to switch to
limit="coniLCS_series"for cheaper evaluation when the conifold modulus is small.How to construct PFV fluxes via
pfv_to_flux, verify the PFV conditions, and walk along the flat direction withpfv_to_modulibefore Newton-refining.How the conifold-PFV pipeline reuses the same scaffolding plus the
ConifoldFreezerto integrate out the conifold modulus, reconstruct the frozen full point, and diagnose the reduced EFT.Prerequisites: NB05 — Finding flux vacua, NB09 — Moduli-space limits (the bulk + series theory).
Outline#
Setup#
import warnings, time, math
import numpy as np
from functools import partial
from scipy.optimize import root
from tqdm.auto import tqdm
import jax
from jax import jit, vmap
import jax.numpy as jnp
jax.config.update("jax_enable_x64", True)
import matplotlib.pyplot as plt
import seaborn as sn
cmap = sn.color_palette("viridis", as_cmap=True)
import jaxvacua as jvc
warnings.filterwarnings("ignore")
Section-local models. Each section uses its own model, constructed once at the top of that section:
Section |
Model name |
Source / parameters |
|---|---|---|
§coni-LCS limit |
|
CYTools polytope (NB03), \(h^{2,1}=3\), |
§General basis |
|
Same CY, |
§Series variant |
|
Same CY, |
§Standard PFVs |
|
Degree-18 hypersurface in \(\mathbb{CP}^{1,1,1,6,9}\), |
§Conifold PFVs |
|
Reuses |
coni-LCS limit in JAXVacua#
We study the original example of 2009.03312 with \(h^{1,1}=99\) and \(h^{1,2}=3\). The mirror dual CY can be defined as follows
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()
cy
To find the desired conifold curve, let us compute GVs to high degree and check for nilpotent rays:
gvs = cy.compute_gvs(max_deg=10)
grading_vector = gvs.grading_vec
gvs = gvs.dok
keys = gvs.keys()
curve_charges = np.array(list(keys))
GV_invariants = np.array(list(gvs.values()))
Check for nilpotent curves
nilpotent_curves = []
for key in keys:
q = np.array(key)
degree = q@grading_vector
if degree==1:
flag=True
for i in range(2,20):
gv=gvs.get(tuple(i*q),0)
if gv!=0:
flag=False
break
if flag:
nilpotent_curves.append([q,gvs[key]])
nilpotent_curves
These are actually the generators of the toric Mori cone
mori = cy.toric_mori_cone(in_basis=True)
mori.extremal_rays()
In the example of 2009.03312, the authors studied the conifold singularity arising when shrinking the curve associated with the class [-1,1,0].
Let us define this as our conifold curve:
conifold_curve = np.array([-1,1,0])
We can then find a suitable basis transformation in which this curve is represented by the class [1,0,0] by calling jaxvacua.get_basis_change:
basis_matrix = jvc.get_basis_change(conifold_curve)
basis_matrix
Let us check that it leads to the expected result
conifold_curve@basis_matrix.T
This choice is not unique; for example, the following integer matrix also works
basis_matrix = np.array([[0, 1, 1], [1, 1, 0], [0, 0, 1]])
conifold_curve@basis_matrix.T
We generate model_coniLCS as follows. Here conifold_curve is specified in the CYTools Mori-cone coordinates, while basis_change rotates the conifold class into the first coordinate used by the coni-LCS prepotential:
basis_matrix = np.array([[0, 1, 1], [1, 1, 0], [0, 0, 1]])
conifold_curve = np.array([-1,1,0])
model_coniLCS = jvc.FluxVacuaFinder(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=100,
conifold_basis=True)
We can check that the data obtained matches Eq. (4.42) in 2009.03312:
model_coniLCS.lcs_tree.a_matrix,model_coniLCS.lcs_tree.b_vector*24
Testing examples from main text in 2009.03312#
Let us reproduce the solution discussed in the main text of section 4.3 of 2009.03312. The choices of \(M\) and \(K\) are
M = np.array([4,-8,8])
K = np.array([-8,3,-6])
We construct the full flux vector with pfv_to_flux. The model already contains the conifold curve and basis-change data, so the PFV integers are converted in the correct conifold basis:
flux = model_coniLCS.pfv_to_flux(M,K)
flux
Let us use as input for finding the true \(F\)-term minimum the PFV solution in the two-term racetrack approximation as stated in 2009.03312
gs = 0.38
tau0 = 1j/gs
z0 = model_coniLCS.pfv_to_moduli(M,K,tau0)
z0
At this initial guess, the \(F\)-term conditions are approximately satisfied
model_coniLCS.DW(z0,jnp.conj(z0),tau0,jnp.conj(tau0),flux)
To find the true minimum, we can run the following lines to search for roots of \(D_I W\):
# Get array of real moduli values (for optimisation purposes)
x0 = model_coniLCS._convert_complex_to_real(z0,jnp.conj(z0),tau0,jnp.conj(tau0))
# Run scipy.optimize.root method to find solutions to DW=0
res = root(model_coniLCS.DW_x,x0,args=(flux,),jac=model_coniLCS.dDW_x,tol=1e-10,method="hybr")
res
Let us compute the value of the F-terms
# Get solution
x1 = res.x
# Compute DW
DW = model_coniLCS.DW_x(x1,flux)
print("DW: ",np.abs(DW))
We check that the solution is inside the facet of the Kähler cone
z1,_,t1,_ = model_coniLCS._convert_real_to_complex(x1)
flag = np.all(z1.imag@model_coniLCS.lcs_tree.hyperplanes.T>0)&(t1.imag>0)
print("Inside facet of Kähler cone: ",flag)
Compute value of \(W_0\) with appropriate normalisation
W1 = model_coniLCS.superpotential(z1,t1,flux,normalise=True)
print("W0: ",np.abs(W1))
This agrees with the expected value \(|W_0|\approx 6.9\times 10^{-4}\) stated in Eq. (4.59) of 2009.03312.
We can also test the value of \(z_{cf}\) at the minimum against analytic expectations using the linear approximation of the superpotential
# Compute z_cf analytically (two approximations) and compare to the numerical result.
# `compute_zcf_x` takes the bulk-only real vector (length 2*h12, no z_cf direction);
# `x1[2:]` strips the (Re, Im) of z_cf from the full real vector x1.
zcf_analytic_pfv = model_coniLCS.compute_zcf_x(x1[2:], flux, mode="pfv")
zcf_analytic_manual = model_coniLCS.compute_zcf_x(x1[2:], flux, mode="autodiff")
zcf_analytic_general = model_coniLCS.compute_zcf_x(x1[2:], flux, mode="manual")
zcf_numerics = z1[0]
print(f"|z_cf| numerically: {np.abs(zcf_numerics):.6e}")
print(f"|z_cf| analytically (PFV): {np.abs(zcf_analytic_pfv):.6e}")
print(f"|z_cf| analytically (bulk VEVs, autodiff): {np.abs(zcf_analytic_general):.6e}")
print(f"|z_cf| analytically (bulk VEVs, manual): {np.abs(zcf_analytic_manual):.6e}")
Test all minima from 2009.03312#
We now test the minima quoted in the main text as well as Table 1 in 2009.03312. Let us write the PFV data as follows
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])
Then we find the associated flux vacuum solving \(D_IW=0\) as follows:
results = []
for i in range(len(Mlist)):
print(f"Testing solution {i+1}/{len(Mlist)} ...")
M = Mlist[i]
K = Klist[i]
flux = model_coniLCS.pfv_to_flux(M, K)
gs = gslist[i]
tau0 = 1j / gs
z0 = model_coniLCS.pfv_to_moduli(M, K, tau0)
x0 = model_coniLCS._convert_complex_to_real(z0, jnp.conj(z0), tau0, jnp.conj(tau0))
res = root(model_coniLCS.DW_x, x0, args=(flux,), jac=model_coniLCS.dDW_x, tol=1e-10, method="hybr")
x1 = res.x
if not res.success:
# Hybrid solver stalled — warm-start with a few Newton steps at smaller step size
z0b, _, tau0b, _ = model_coniLCS._convert_real_to_complex(x1)
z1n, taun, _ = model_coniLCS.newton_method_flux_vacua(
z0b, tau0b, flux,
step_size_Newton=0.1, tol=1e-12, max_iters=100,
mode="SUSY", solver_mode="real"
)
x0 = model_coniLCS._convert_complex_to_real(z1n, jnp.conj(z1n), taun, jnp.conj(taun))
res = root(model_coniLCS.DW_x, x0, args=(flux,), jac=model_coniLCS.dDW_x, tol=1e-10, method="hybr")
x1 = res.x
DW = model_coniLCS.DW_x(x1, flux)
z1, _, t1, _ = model_coniLCS._convert_real_to_complex(x1)
in_cone = bool(
np.all(z1.imag @ model_coniLCS.lcs_tree.hyperplanes.T > 0) and t1.imag > 0
)
W0 = float(np.abs(model_coniLCS.superpotential(z1, t1, flux, normalise=True)))
zcf_num = complex(z1[0])
zcf_pfv = complex(model_coniLCS.compute_zcf_x(x1[2:], flux, mode="pfv"))
zcf_bulk = complex(model_coniLCS.compute_zcf_x(x1[2:], flux, mode="manual"))
dw_norm = float(np.sum(np.abs(DW)))
results.append({
"i": i,
"converged": res.success,
"DW_norm": dw_norm,
"in_cone": in_cone,
"W0": W0,
"zcf_num": zcf_num,
"zcf_pfv": zcf_pfv,
"zcf_bulk": zcf_bulk,
})
print("\nDone.")
fig, axes = plt.subplots(1, 2, figsize=(11, 4), dpi=150)
sol_labels = [f"Sol. {r['i']+1}" for r in results]
zcf_nums = [np.abs(r["zcf_num"]) for r in results]
zcf_pfvs = [np.abs(r["zcf_pfv"]) for r in results]
zcf_bulks = [np.abs(r["zcf_bulk"]) for r in results]
W0s = [r["W0"] for r in results]
x_pos = np.arange(len(results))
# Left panel: compare z_cf estimates
ax = axes[0]
ax.semilogy(x_pos, zcf_nums, "o-", label=r"$|z_{\rm cf}|$ numerical", color="C0", ms=7)
ax.semilogy(x_pos, zcf_pfvs, "s--", label=r"$|z_{\rm cf}|$ PFV analytic", color="C1", ms=7)
ax.semilogy(x_pos, zcf_bulks, "^:", label=r"$|z_{\rm cf}|$ bulk VEVs", color="C2", ms=7)
ax.set_xticks(x_pos)
ax.set_xticklabels(sol_labels, rotation=20)
ax.set_ylabel(r"$|z_{\rm cf}|$", fontsize=12)
ax.set_title(r"Conifold modulus $|z_{\rm cf}|$", fontsize=12)
ax.legend(fontsize=9)
ax.grid(True, which="both", alpha=0.3)
# Right panel: |W_0| at each minimum
ax2 = axes[1]
ax2.semilogy(x_pos, W0s, "D-", color="C3", ms=7)
ax2.set_xticks(x_pos)
ax2.set_xticklabels(sol_labels, rotation=20)
ax2.set_ylabel(r"$|W_0|$", fontsize=12)
ax2.set_title(r"Superpotential $|W_0|$ at minimum", fontsize=12)
ax2.grid(True, which="both", alpha=0.3)
plt.suptitle("All solutions from Table 1 of arXiv:2009.03312", fontsize=11, y=1.02)
plt.tight_layout()
plt.show()
from IPython.display import display, HTML
header = ["Sol.", "|W₀|", "|z_cf| (num.)", "|z_cf| (PFV)", "|z_cf| (bulk VEVs)", "∑|DW|", "Kähler cone", "Conv."]
rows = []
for r in results:
rows.append([
str(r["i"] + 1),
f"{r['W0']:.3e}",
f"{np.abs(r['zcf_num']):.3e}",
f"{np.abs(r['zcf_pfv']):.3e}",
f"{np.abs(r['zcf_bulk']):.3e}",
f"{r['DW_norm']:.2e}",
"✓" if r["in_cone"] else "✗",
"✓" if r["converged"] else "(warmed)",
])
th = "".join(f"<th style='padding:6px 10px'>{h}</th>" for h in header)
trs = "".join(
"<tr>" + "".join(f"<td style='padding:5px 10px;text-align:center'>{v}</td>" for v in row) + "</tr>"
for row in rows
)
display(HTML(f"""
<table border='1' cellspacing='0' style='border-collapse:collapse;font-size:13px'>
<thead><tr>{th}</tr></thead>
<tbody>{trs}</tbody>
</table>
"""))
General basis: the conifold_basis=False pipeline#
So far every model in this section has been built with conifold_basis=True: an integer basis change \(\Lambda\) (basis_change=L) is applied up front so that the chosen conifold curve becomes the canonical class \((1, 0, \dots, 0)\) and the conifold modulus is simply \(z^0\). This aligned frame is convenient because every “conifold” / “bulk” projection collapses to a plain array slice.
The same coni-LCS pipeline — periods, K”ahler potential, gauge-kinetic matrix, superpotential and F-terms, ISD sampling, pfv_to_flux, pfv_to_moduli, compute_zcf in all three modes (manual / autodiff / pfv), the ConifoldFreezer that integrates out the conifold modulus, and map_to_fd — also runs in the general basis with conifold_basis=False, where the original (unrotated) conifold charge \(q\) is carried through directly. In that frame the conifold modulus is the charge combination \(z_{\mathrm{cf}} = q \cdot z\) and the bulk moduli are extracted by an explicit projection matrix.
The four lattice objects that organise this split are carried on model.lcs_tree.conifold:
name |
shape |
role |
|---|---|---|
|
\((h^{1,2})\) |
conifold direction of a contravariant quantity (moduli, periods, \(f_2,h_2\) fluxes): \(z_{\mathrm{cf}} = q\cdot z\) |
|
\((h^{1,2})\) |
conifold direction of a covariant quantity (charges, \(f_1,h_1\) fluxes): \(f_{\mathrm{cf}} = e_q\cdot f\) |
|
\((h^{1,2},\, h^{1,2}-1)\) |
embed bulk vector \(\to\) full: \(z = z_{\mathrm{cf}}\, e_q + \text{bulk\_embedding}\cdot z_{\mathrm{bulk}}\) |
|
\((h^{1,2},\, h^{1,2}-1)\) |
project full \(\to\) bulk for a contravariant vector: \(z_{\mathrm{bulk}} = z\,@\,\text{bulk\_projection}\) |
They satisfy \(q\cdot e_q = 1\), \(q\,@\,\text{bulk\_embedding} = 0\), \(e_q\,@\,\text{bulk\_projection} = 0\), and \(\text{bulk\_embedding}^{\!\top}\,@\,\text{bulk\_projection} = \mathbb{1}_{h^{1,2}-1}\). In the conifold-aligned basis (\(q = e_q = (1, 0, \dots)\), \(\text{bulk\_embedding} = \text{bulk\_projection} = [0;\mathbb{1}_{h^{1,2}-1}]\)) every projection collapses back to a plain slice, so all results below reproduce bit-identically what the aligned model gave us in the previous subsection.
We build the general-basis counterpart of model_coniLCS using the same CY data, the same conifold curve [-1, 1, 0], the same instanton truncation, but with no basis_change and conifold_basis=False:
model_coniLCS_general = jvc.FluxVacuaFinder(
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=None, # no basis change
conifold_curve=conifold_curve,
limit="coniLCS",
prange=100,
conifold_basis=False, # keep q in the original frame
)
print(f"conifold_basis = {model_coniLCS_general.lcs_tree.conifold_basis}")
print(f"conifold_curve (q) = "
f"{np.asarray(model_coniLCS_general.lcs_tree.conifold.conifold_curve).astype(int).tolist()}")
Pull the four projection objects from the lcs_tree.conifold container and verify the lattice identities:
co = model_coniLCS_general.lcs_tree.conifold
q = np.asarray(co.conifold_curve)
e_q = np.asarray(co.embedding)
be = np.asarray(co.bulk_embedding)
bp = np.asarray(co.bulk_projection)
print(f"q = {q.astype(int).tolist()}")
print(f"e_q = {e_q.astype(int).tolist()}")
print(f"bulk_embedding =\n{be.astype(int)}")
print(f"bulk_projection =\n{bp.astype(int)}")
print()
print("Lattice identities:")
print(f" q . e_q = {int(q @ e_q)} (should be 1)")
print(f" q @ bulk_embedding = {(q @ be).astype(int).tolist()} (should be [0, 0])")
print(f" e_q @ bulk_projection = {(e_q @ bp).astype(int).tolist()} (should be [0, 0])")
print(f" bulk_embedding.T @ bulk_projection =\n{(be.T @ bp).astype(int)}")
Decomposing vacuum #1 into \(z_{\mathrm{cf}}\) and \(z_{\mathrm{bulk}}\)#
Apply the projection objects to the first vacuum found above (M = (4, -8, 8), K = (-8, 3, -6)). In the general basis the conifold modulus and the bulk moduli are recovered from the full \(h^{1,2}\)-vector by
and the full vector reconstructs (exactly, by the resolution of identity above) as
# Same M, K as vacuum #1; build the PFV-projected moduli at tau = 5j directly
# from the general-basis model.
M_1 = np.array([4, -8, 8])
K_1 = np.array([-8, 3, -6])
flux_1_general = model_coniLCS_general.pfv_to_flux(M_1, K_1)
tau_1 = 5.0j
z_1_general = np.asarray(model_coniLCS_general.pfv_to_moduli(M_1, K_1, tau_1))
z_cf_1 = q @ z_1_general
z_bulk_1 = z_1_general @ bp
z_recon = z_cf_1 * e_q + be @ z_bulk_1
print(f"z (general basis) = {z_1_general}")
print(f"z_cf = q . z = {z_cf_1}")
print(f"z_bulk = z @ bp = {z_bulk_1}")
print()
print(f"z_cf * e_q + be @ z_bulk = {z_recon}")
print(f"max |reconstruction err| = {float(np.max(np.abs(z_recon - z_1_general))):.2e}")
Decomposing the flux vector#
Fluxes split symplectically. In the GVW pairing the \(f_1, h_1\) blocks pair with the electric periods \(X^I\) (covariant, like charges), so the conifold/bulk pieces are extracted via \(e_q\) and bulk_embedding. The \(f_2, h_2\) blocks pair with the magnetic periods \(\mathcal{F}_I\) (contravariant, like moduli), so they are extracted via \(q\) and bulk_projection. In the conifold-aligned basis all four collapse to plain slices.
The helper model.conifold_fluxes(flux) returns the canonical split
with \(M_1, H_1\) the conifold pieces of \(f_2, h_2\) (extracted via \(q\)) and \(P_1, K_1\) the conifold pieces of \(f_1, h_1\) (extracted via \(e_q\)).
(M0, H0, M1_, H1_, Malpha, Halpha,
P1_, K1_, Palpha, Kalpha, P0, K0) = model_coniLCS_general.conifold_fluxes(flux_1_general)
print(f"flux (general basis) = {np.asarray(flux_1_general).astype(int).tolist()}")
print()
print(f"f2, h2 (contravariant): conifold via q, bulk via bulk_projection")
print(f" M_1 = q . f2[1:] = {float(M1_):+.0f}")
print(f" H_1 = q . h2[1:] = {float(H1_):+.0f}")
print(f" M_alpha = f2[1:] @ bp = {np.asarray(Malpha).astype(int).tolist()}")
print(f" H_alpha = h2[1:] @ bp = {np.asarray(Halpha).astype(int).tolist()}")
print()
print(f"f1, h1 (covariant): conifold via e_q, bulk via bulk_embedding")
print(f" P_1 = e_q . f1[1:] = {float(P1_):+.0f}")
print(f" K_1 = e_q . h1[1:] = {float(K1_):+.0f}")
print(f" P_alpha = f1[1:] @ be = {np.asarray(Palpha).astype(int).tolist()}")
print(f" K_alpha = h1[1:] @ be = {np.asarray(Kalpha).astype(int).tolist()}")
Integrating out the conifold modulus#
The ConifoldFreezer reads the same projection objects: in the general basis it identifies the heavy direction via \(q\) and treats the \(h^{1,2}-1\) bulk directions (recovered through bulk_projection) as the light fields. Construction looks identical to the aligned-basis case — the freezer just picks up the right projection objects from model.lcs_tree.conifold. (The detailed reduced-EFT solve is covered in §Conifold PFVs.)
freezer_general = jvc.ConifoldFreezer(model_coniLCS_general)
print(f"heavy indices = {freezer_general.heavy_indices}")
print(f"light indices = {freezer_general.light_indices}")
print(f"n_heavy = {freezer_general.n_heavy}")
print(f"n_light = {freezer_general.n_light}")
Series-expansion variant#
The coniLCS_series limit implements a linearised approximation of the conifold prepotential.
Near the conifold locus \(z^1 \to 0\), the one-loop worldsheet correction involving the conifold modulus is dominated by the leading logarithm of the dilogarithm. Concretely, the prepotential takes the form
The coniLCS_series mode retains only the leading linear term in \(z^1\) inside the logarithm, i.e. it replaces the full dilogarithm \(\text{Li}_2(e^{2\pi i z^1})\) by its leading-log expansion. This gives a simpler analytic handle on the vacuum structure when \(|z^1| \ll 1\):
When to use it:
As a cheap cross-check: the
coniLCS_seriesminimum should match the fullconiLCSminimum for solutions with \(|z_{\rm cf}| \ll 1\).For analytic estimates of \(z_{\rm cf}\) before running the full Newton solver.
For solutions with larger \(|z_{\rm cf}|\) the series approximation breaks down; use the full
coniLCSprepotential in that case.
We instantiate a model using limit="coniLCS_series" — all other parameters are identical to the coniLCS model above:
model_series = jvc.FluxVacuaFinder(
h12=cy.h11(), Q=cy.h11()+cy.h12()+2,
use_cytools=True,
mirror_cy=cy,
conifold_curve=conifold_curve,
ncf=2,
use_gvs=True,
maximum_degree=6,
basis_change=basis_matrix,
limit="coniLCS_series",
prange=10,
conifold_basis=True,
nmax=2
)
We compare coniLCS vs coniLCS_series for all five solutions. For vacua with \(|z_{\rm cf}| \ll 1\) (solutions 1–2) the two prepotentials should give nearly identical \(|W_0|\) and \(|z_{\rm cf}|\); for the solution with larger \(|z_{\rm cf}|\) (solution 4) we expect a visible discrepancy.
results_series = []
for i in tqdm(range(len(Mlist))):
M = Mlist[i]
K = Klist[i]
flux = model_series.pfv_to_flux(M, K)
gs = gslist[i]
tau0 = 1j / gs
z0 = model_series.pfv_to_moduli(M, K, tau0)
x0 = model_series._convert_complex_to_real(z0, jnp.conj(z0), tau0, jnp.conj(tau0))
res = root(model_series.DW_x, x0, args=(flux,), jac=model_series.dDW_x, tol=1e-10, method="hybr")
x1 = res.x
if not res.success:
z0b, _, tau0b, _ = model_series._convert_real_to_complex(x1)
z1n, taun, _ = model_series.newton_method_flux_vacua(
z0b, tau0b, flux,
step_size_Newton=0.1, tol=1e-12, max_iters=100,
mode="SUSY", solver_mode="real"
)
x0 = model_series._convert_complex_to_real(z1n, jnp.conj(z1n), taun, jnp.conj(taun))
res = root(model_series.DW_x, x0, args=(flux,), jac=model_series.dDW_x, tol=1e-10, method="hybr")
x1 = res.x
z1, _, t1, _ = model_series._convert_real_to_complex(x1)
W0_series = float(np.abs(model_series.superpotential(z1, t1, flux, normalise=True)))
zcf_series = float(np.abs(z1[0]))
results_series.append({"W0": W0_series, "zcf_num": zcf_series, "converged": res.success})
print("Done.")
Let us plot the results below
fig, axes = plt.subplots(1, 2, figsize=(11, 4), dpi=150)
sol_labels = [f"Sol. {i+1}" for i in range(len(results))]
x_pos = np.arange(len(results))
W0_full = [r["W0"] for r in results]
W0_ser = [r["W0"] for r in results_series]
zcf_full_arr = [np.abs(r["zcf_num"]) for r in results]
zcf_ser_arr = [r["zcf_num"] for r in results_series]
ax = axes[0]
ax.semilogy(x_pos, W0_full, "o-", color="C0", label="coniLCS (full)", ms=7)
ax.semilogy(x_pos, W0_ser, "s--", color="C1", label="coniLCS_series", ms=7)
ax.set_xticks(x_pos)
ax.set_xticklabels(sol_labels, rotation=20)
ax.set_ylabel(r"$|W_0|$", fontsize=12)
ax.set_title(r"$|W_0|$: full vs. series", fontsize=12)
ax.legend(fontsize=9)
ax.grid(True, which="both", alpha=0.3)
ax2 = axes[1]
ax2.semilogy(x_pos, zcf_full_arr, "o-", color="C0", label="coniLCS (full)", ms=7)
ax2.semilogy(x_pos, zcf_ser_arr, "s--", color="C1", label="coniLCS_series", ms=7)
ax2.set_xticks(x_pos)
ax2.set_xticklabels(sol_labels, rotation=20)
ax2.set_ylabel(r"$|z_{\rm cf}|$", fontsize=12)
ax2.set_title(r"$|z_{\rm cf}|$: full vs. series", fontsize=12)
ax2.legend(fontsize=9)
ax2.grid(True, which="both", alpha=0.3)
plt.suptitle(
r"Full coniLCS prepotential vs. linearised series approximation",
fontsize=11, y=1.02
)
plt.tight_layout()
plt.show()
The comparison shows the intended regime of the coniLCS_series approximation. When \(|z_{
\mathrm{cf}}|\) is very small, the series and full coni-LCS prepotentials agree closely. The visible outlier is precisely the solution with the largest conifold modulus: there the truncated series is being pushed outside its most reliable regime, so both \(|W_0|\) and \(|z_{
\mathrm{cf}}|\) drift away from the full calculation.
Standard PFVs at LCS#
Setup: degree-18 hypersurface#
We use the degree-18 hypersurface in \(\mathbb{CP}_{[1,1,1,6,9]}\) with \(h^{2,1} = 2\) (on the \(\mathbb{Z}_6 \times \mathbb{Z}_{18}\)-invariant locus) and \(Q_{\mathrm{D3}} = 138\). This is the canonical PFV example from 1903.00596 and 2512.17095, Section 6.2.
model_LCS = jvc.FluxVacuaFinder(h12=2, model_ID=1, maximum_degree=2, limit="LCS", model_type="KS")
print(model_LCS)
print(f"D3 tadpole: {model_LCS.D3_tadpole}")
print(f"Intersection numbers: {model_LCS.lcs_tree.intnums.astype(int)}")
Constructing PFV fluxes#
The PFV flux ansatz is:
where \(R_0\) and \(R_a\) are fixed by the integrality conditions. The pfv_to_flux method constructs the full flux vector from \((M^a, K_a)\).
# PFV flux vectors from Section 6.2 of arXiv:2512.17095
M = jnp.array([-16., 50.])
K = jnp.array([3., -4.])
# Construct the full flux vector
flux_pfv = model_LCS.pfv_to_flux(M, K)
print(f"Full flux vector: {flux_pfv.astype(int)}")
# Verify tadpole
Nflux = model_LCS.tadpole(flux_pfv)
print(f"N_flux = {int(Nflux)} (must be ≤ {model_LCS.D3_tadpole})")
Verifying the PFV conditions#
# N_{ab} = kappa_{abc} M^c
kappa = model_LCS.lcs_tree.intnums
N = jnp.einsum('ijk,k->ij', kappa, M)
print(f"N_ab = \n{N.astype(int)}")
print(f"det(N) = {int(jnp.linalg.det(N))} (must be ≠ 0) ✓")
# p^a = N^{-1} K
p = jnp.linalg.inv(N) @ K
print(f"\np = {p} (direction vector)")
print(f"K · p = {float(K @ p):.6e} (must be 0) ✓")
# Integrality: a_matrix @ M must be integer
a = model_LCS.lcs_tree.a_matrix
aM = a @ M
print(f"\na_ab M^b = {aM} (must be integer) ✓")
# c-vector check: c · M must be divisible by 24
b = model_LCS.lcs_tree.b_vector
cM = 24 * b @ M
print(f"c_a M^a = {float(cM):.0f} (must be in 24Z) ✓")
Computing the PFV flat direction#
Along the PFV locus \(z^a = p^a \tau\), the polynomial superpotential vanishes. The pfv_to_moduli method computes the moduli at this locus for a given \(\tau\).
# Evaluate |W| along the flat direction for a range of Im(tau)
tau_values = jnp.linspace(4, 12, 200) * 1j
W_along_flat = []
for tau_v in tau_values:
z_v = model_LCS.pfv_to_moduli(M, K, tau_v)
W_v = model_LCS.superpotential(z_v, tau_v, flux_pfv)
W_along_flat.append(float(jnp.abs(W_v)))
W_along_flat = np.array(W_along_flat)
fig, ax = plt.subplots()
ax.semilogy(np.array(tau_values.imag), W_along_flat, color="#2563eb", lw=1.5)
ax.set_xlabel(r"Im$(\tau)$")
ax.set_ylabel(r"$|W_{\mathrm{flux}}|$")
ax.set_title("Superpotential along the PFV flat direction")
ax.axvline(x=6.86, color="#dc2626", ls="--", lw=0.8, alpha=0.7, label=r"$\langle\tau\rangle$")
ax.legend()
fig.tight_layout()
plt.show()
# The minimum gives the racetrack vacuum
idx_min = np.argmin(W_along_flat)
print(f"Minimum |W| = {W_along_flat[idx_min]:.4e} at Im(tau) = {float(tau_values[idx_min].imag):.2f}")
Newton refinement to the exact vacuum#
The PFV flat direction provides an excellent initial guess. We refine with Newton’s method (real solver mode, damped step size) to find the exact SUSY vacuum where \(D_aW = 0\).
# Initial guess from PFV
tau_init = 6.85j
z_init = model_LCS.pfv_to_moduli(M, K, tau_init)
print(f"PFV initial guess:")
print(f" z = {z_init}")
print(f" tau = {tau_init}")
print(f" |W| = {float(jnp.abs(model_LCS.superpotential(z_init, tau_init, flux_pfv))):.4e}")
# Newton refinement (real solver, damped)
z_sol, tau_sol, res = model_LCS.newton_method_flux_vacua(
z_init, tau_init, flux_pfv,
mode=None, step_size_Newton=0.1,
tol=1e-12, max_iters=500, solver_mode="real"
)
# Results
DW = model_LCS.DW(z_sol, jnp.conj(z_sol), tau_sol, jnp.conj(tau_sol), flux_pfv)
W0 = model_LCS.superpotential(z_sol, tau_sol, flux_pfv)
W0_gi = model_LCS.superpotential_gauge_invariant(z_sol, tau_sol, flux_pfv)
print(f"\nPFV vacuum found:")
print(f" z = {z_sol}")
print(f" tau = {tau_sol}")
print(f" |DW| = {float(jnp.sum(jnp.abs(DW))):.2e}")
print(f" |W| = {float(jnp.abs(W0)):.6e}")
print(f" |W0| (gauge-invariant) = {float(jnp.abs(W0_gi)):.6e}")
print(f" N_flux = {int(model_LCS.tadpole(flux_pfv))}")
print(f" Residual = {float(res):.2e}")
The racetrack mechanism#
The exponentially small \(|W_0|\) arises from a racetrack — two competing instanton contributions with nearly equal exponents. The effective superpotential along the flat direction is:
When \(|p^1 - p^2| \ll p^2\), the two terms nearly cancel at the minimum, producing \(|W_0| \ll 1\).
# Show the two leading instanton contributions
print(f"PFV direction: p = {p}")
print(f"p1 = {float(p[0]):.4f}")
print(f"p2 = {float(p[1]):.4f}")
print(f"|p1 - p2| = {float(jnp.abs(p[0] - p[1])):.4f}")
print(f"|p1 - p2| / p2 = {float(jnp.abs(p[0] - p[1]) / jnp.abs(p[1])):.4f}")
print()
print(f"At the vacuum:")
print(f" exp(2πi p1 τ) ~ exp(-2π × {float(p[0]):.3f} × {float(tau_sol.imag):.3f})")
print(f" = {float(jnp.exp(-2*jnp.pi*p[0]*tau_sol.imag)):.4e}")
print(f" exp(2πi p2 τ) ~ exp(-2π × {float(p[1]):.3f} × {float(tau_sol.imag):.3f})")
print(f" = {float(jnp.exp(-2*jnp.pi*p[1]*tau_sol.imag)):.4e}")
digits = int(-np.log10(float(jnp.exp(-2*jnp.pi*p[0]*tau_sol.imag))))
print(f"\n→ Both contributions are O(10^-{digits}) and nearly cancel → |W0| ≈ {float(jnp.abs(W0_gi)):.2e}")
Conifold PFVs#
Loading a conifold model#
# Reuse the CYTools coni-LCS model constructed above.
# The small packaged catalogue entry `h12=3, model_ID=1` does not carry the
# conifold metadata needed for this section, whereas `model_coniLCS` was built
# explicitly with `conifold_curve`, `basis_change`, and `ncf`.
try:
model_coni = model_coniLCS
print(model_coni)
print(f"h12 = {model_coni.h12}")
print(f"limit = {model_coni.periods.limit}")
print(f"n_cf = {model_coni.lcs_tree.conifold.ncf}")
print(f"conifold curve = {np.asarray(model_coni.lcs_tree.conifold.conifold_curve).astype(int).tolist()}")
HAS_CONI = True
except NameError as e:
print(f"ConiLCS model not available in this session: {e}")
print("Skipping conifold PFV section. Run the coni-LCS setup cells above first.")
HAS_CONI = False
Conifold PFV construction#
For a conifold PFV, the bulk moduli \(z^\alpha\) are stabilised via the same PFV mechanism as before, while the conifold modulus \(z_{\mathrm{cf}}\) is stabilised by the logarithmic monodromy.
The conifold PFV conditions are the same as the standard PFV conditions but applied to the bulk indices \(\alpha\) only:
where \(\tilde{c}'_a = \tilde{c}_a + n_{\mathrm{cf}} \delta_{a,1}\) accounts for the shifted constant term.
if HAS_CONI:
# Conifold PFV: the pfv_to_moduli method handles the conifold case automatically
M_coni = np.array([4,-8,8]) # Example M-vector
K_coni = np.array([-8,3,-6]) # Example K-vector
try:
flux_coni = model_coni.pfv_to_flux(M_coni, K_coni)
print(f"Conifold PFV flux: {flux_coni.astype(int)}")
tau_coni = 5.0j
z_coni = model_coni.pfv_to_moduli(M_coni, K_coni, tau_coni)
print(f"\nModuli at PFV locus:")
print(f" z_cf (conifold) = {z_coni[0]}")
print(f" z_bulk = {z_coni[1:]}")
print(f" |z_cf| = {float(jnp.abs(z_coni[0])):.4e}")
except Exception as e:
print(f"Conifold PFV construction: {e}")
print("(This may require specific flux choices satisfying the PFV conditions.)")
else:
print("Skipped (coniLCS model not available)")
The ConifoldFreezer#
The ConifoldFreezer is the reduced-EFT layer used once the conifold PFV data have been converted into a full flux vector. Its role is to solve the heavy conifold modulus analytically,
and then evaluate observables on the remaining light fields \((z^\alpha,\tau)\). This is useful for three practical reasons:
it removes the steep logarithmic conifold direction from the numerical solve;
it gives a direct diagnostic for whether the PFV/bulk approximation predicts the same \(|z_{\mathrm{cf}}|\) as the full coni-LCS model;
it provides reduced F-terms for bulk-only Newton refinement and ensemble scans.
In this notebook we only show how the freezer enters the conifold PFV pipeline. The detailed derivation of the \(z_{\mathrm{cf}}\) equation of motion, the \(\widetilde W_1\) term, and the reduced real-coordinate interface is given in NB12 — Freezer.
if HAS_CONI:
try:
freezer = jvc.ConifoldFreezer(model_coni)
FREEZER_READY = True
print(f"ConifoldFreezer:")
print(f" Heavy indices: {freezer.heavy_indices}")
print(f" Light indices: {freezer.light_indices}")
print(f" n_heavy = {freezer.n_heavy}")
print(f" n_light = {freezer.n_light}")
except Exception as e:
FREEZER_READY = False
print(f"ConifoldFreezer: {e}")
else:
FREEZER_READY = False
print("Skipped (coniLCS model not available)")
The previous cell only constructs the wrapper. The actual application is to pass the PFV bulk point and the full flux vector to the freezer. The freezer then reconstructs the full moduli vector with the analytic conifold solution inserted, evaluates the reduced superpotential, and exposes the light-field F-terms. A small residual here should be interpreted as a reduced-EFT diagnostic at the frozen point, not as a substitute for the full-vacuum checks above.
if FREEZER_READY and "flux_coni" in globals() and "z_coni" in globals():
z_bulk_coni = z_coni[1:]
zcf_frozen = freezer.solve_heavy(z_bulk_coni, tau_coni, flux_coni, mode="manual")
z_full_frozen = freezer.reconstruct_full_moduli(
z_bulk_coni, tau_coni, flux_coni, mode="manual"
)
W_reduced = freezer.superpotential(
z_bulk_coni, tau_coni, flux_coni, mode="manual"
)
DW_light = freezer.DW_light(
z_bulk_coni,
jnp.conj(z_bulk_coni),
tau_coni,
jnp.conj(tau_coni),
flux_coni,
mode="manual",
)
print("Reduced conifold-PFV diagnostic:")
print(f" |z_cf| from pfv_to_moduli: {float(jnp.abs(z_coni[0])):.4e}")
print(f" |z_cf| from freezer: {float(jnp.abs(zcf_frozen[0])):.4e}")
print(f" |W_reduced|: {float(jnp.abs(W_reduced)):.4e}")
print(f" max |DW_light|: {float(jnp.max(jnp.abs(DW_light))):.4e}")
print(f" reconstructed z: {np.asarray(z_full_frozen)}")
else:
print("Skipped (freezer or conifold PFV data not available)")
Take-aways#
limit="coniLCS"activates the full conifold-aware prepotential. Construction needs the CY geometry (via CYTools or the catalogue), theconifold_curveMori-cone coordinate, the shrinking-cycle countncf, and abasis_changethat puts the conifold modulus in the first slot. Validated against the five worked examples of arXiv:2009.03312.limit="coniLCS_series"is a cheaper Taylor-truncated variant. Identical interface toconiLCS— only thelimitstring changes. For \(|z_{\rm cf}| \ll 1\) it matchesconiLCSto machine precision; for larger \(|z_{\rm cf}|\) a visible drift appears (compare results in §Series).pfv_to_flux(M, K)constructs PFV flux vectors directly from the integer quantum numbers \((M, K)\) of arXiv:1903.00596. The PFV condition \(N_{ab} M^b = 0\) where \(N_{ab} = \kappa_{abc} M^c\) gives the flat-direction quanta.pfv_to_moduli(M, K, tau)computes the moduli on the PFV flat direction \(z^a = p^a \tau\). This is an excellent initial guess for Newton’s method — one Newton sweep then lands the exact vacuum.The racetrack mechanism at LCS produces exponentially-small \(|W_0|\): subdominant instanton corrections compete with the leading one and a fine-tuned tau cancels them. The PFV pipeline lands directly inside the cancellation basin.
Conifold PFVs extend the same construction to the coni-LCS limit: bulk moduli are stabilised via the PFV mechanism, while \(z_{\rm cf}\) is integrated out by
ConifoldFreezer. The freezer is useful because it removes the steep logarithmic direction, reconstructs the frozen full point, and exposes reduced F-terms for the light fields. See NB12 — Freezer for the full \(\widetilde W_1\) derivation and reduced-coordinate validation.
Further reading#
NB09 — Moduli-space limits (bulk + series theory)
arXiv:1903.00596 (PFV mechanism), arXiv:2004.10740 (conifold PFVs), arXiv:2009.03312 (coniLCS examples)