Files
Calabi-Yau-Period-Geometry/tests/test_period_computation.py
T
Julian Piribauer 7217598d73
CI / lint (push) Successful in 1m20s
CI / test (push) Successful in 3m49s
Implementing Frobenius basis for PFIdeals
2026-11-01 16:46:13 +01:00

448 lines
20 KiB
Python

import logging
import os
import pytest
from sage.all import * # noqa: F401
load(os.path.join(os.path.dirname(__file__), "..", "sage", "periods.sage"))
def _quintic_ideal():
# The quintic's Picard-Fuchs operator, of rank 4 with log structure [1, 1, 1, 1] at its MUM point.
return PFIdeal(
[PFOperator("theta0^4 - 5*z0*(5*theta0 + 1)*(5*theta0 + 2)*(5*theta0 + 3)*(5*theta0 + 4)", no_variables=1)],
hodge_numbers=[1, 1, 1, 1],
)
def _p22211_ideal():
# The Picard-Fuchs ideal of P_{22211}[8] at its MUM point (see
# test_ideal_finds_power_series_solution_and_recovers_first_operator), with h^{2,1} = 2.
D1 = PFOperator(
"theta0^2*(theta0 - 2*theta1) - 4*z0*(4*theta0 + 3)*(4*theta0 + 2)*(4*theta0 + 1)",
no_variables=2,
)
D2 = PFOperator(
"theta1^2 - z1*(2*theta1 - theta0 + 1)*(2*theta1 - theta0)",
no_variables=2,
)
return PFIdeal([D1, D2], hodge_numbers=[1, 2, 2, 1])
def _p333111_ideal():
# The Picard-Fuchs ideal of the fourfold P_{333111}[12] at its MUM point: the GKZ operators of
# l^(1) = (-4; 1,1,1,0,0,0,1) and l^(2) = (0; 0,0,0,1,1,1,-3), the first without its left factor theta0.
# With the intersection numbers kappa_0000 = 36, kappa_0001 = 12, kappa_0011 = 4 (and 0 else) that
# toric_topdata computes, the vertical h^{2,2} is 3.
D1 = PFOperator(
"theta0^2*(theta0 - 3*theta1) - 4*z0*(4*theta0 + 1)*(4*theta0 + 2)*(4*theta0 + 3)",
no_variables=2,
)
D2 = PFOperator(
"theta1^3 - z1*(theta0 - 3*theta1)*(theta0 - 3*theta1 - 1)*(theta0 - 3*theta1 - 2)",
no_variables=2,
)
return PFIdeal([D1, D2], hodge_numbers=[1, 2, 3, 2, 1])
def _starting_term(period):
# A solution's lowest z-monomial (by total degree, then lexicographically) together with its coefficient
# per log-monomial there, e.g. ((1,), {(1,): 1}) for z0*log(z0) + ...
z_index = min(
(z_index for z_dict in period.coefficients.values() for z_index, coeff in z_dict.items() if coeff != 0),
key=lambda z_index: (sum(z_index), z_index),
)
return z_index, {
log_index: z_dict[z_index] for log_index, z_dict in period.coefficients.items() if z_dict[z_index] != 0
}
def _nonzero(coefficients):
# A coefficients dictionary without its zero entries, to compare bases that store zeros differently.
return {
log_index: {z_index: coeff for z_index, coeff in z_dict.items() if coeff != 0}
for log_index, z_dict in coefficients.items()
if any(coeff != 0 for coeff in z_dict.values())
}
def _truncated(f, degree):
# A polynomial without its terms of total degree above degree.
return sum((coeff * monomial for coeff, monomial in f if monomial.degree() <= degree), f.parent()(0))
def _inverse_of_linear(constant, linear, degree):
# 1/(constant + linear) for a linear form in eps, as a polynomial truncated at the given degree.
return sum(((-linear / constant) ** j for j in range(degree + 1)), linear.parent()(0)) / constant
def _classical_frobenius_periods(coefficient_ratio, starting_terms, order):
# The classical Frobenius method. With coefficient_ratio(k) = c(k + eps)/c(eps) for the Gamma-function
# coefficients c of the fundamental period (a polynomial in eps, truncated at the starting terms' degree),
# the period starting with Q(log z) is Q(d/deps) applied to sum_k c(k + eps)/c(eps) * z^(k + eps) at eps = 0.
n = starting_terms[0].parent().ngens()
log_ring = PolynomialRing(QQ, n, ["L%d" % i for i in range(n)])
logs = log_ring.gens()
periods = [{} for _ in starting_terms]
for k in _multi_indices(n, order):
ratio = coefficient_ratio(k)
for period, starting_term in zip(periods, starting_terms):
# d^alpha/deps^alpha of ratio * exp(eps . L) at eps = 0, summed over the monomials eps^alpha of Q
value = log_ring(0)
for q, q_monomial in starting_term:
alpha = q_monomial.exponents()[0]
for r, r_monomial in ratio:
beta = r_monomial.exponents()[0]
if all(beta[i] <= alpha[i] for i in range(n)):
value += q * r * prod(
factorial(alpha[i]) // factorial(alpha[i] - beta[i]) * logs[i] ** (alpha[i] - beta[i])
for i in range(n)
)
for coeff, log_monomial in value:
period.setdefault(tuple(log_monomial.exponents()[0]), {})[k] = coeff
return periods
def test_find_power_series_solution_recovers_quintic_period():
# The quintic's Picard-Fuchs operator has indicial equation theta0^4 = 0 at z0 = 0
# (a quadruple root at 0), so its holomorphic (non-logarithmic) power series solution
# is found at indicial 0. Up to normalisation this is the classic quintic period
# 1 + 120*z0 + 113400*z0^2 + ...
op = PFOperator(
"theta0^4 - 5*z0*(5*theta0 + 1)*(5*theta0 + 2)*(5*theta0 + 3)*(5*theta0 + 4)",
no_variables=1,
)
ideal = PFIdeal([op])
solutions = ideal.find_power_series_solution(indicials=[0], order=3)
assert len(solutions) == 1
assert solutions[0].coefficients == {(0,): {(0,): 1, (1,): 120, (2,): 113400, (3,): 168168000}}
def test_find_power_series_solution_handles_rational_indicial():
# theta0 - 1/2 kills z0^(1/2 + k) only when k = 0, since its eigenvalue is 1/2 + k;
# so at indicial 1/2 there is exactly one solution (a constant multiple of sqrt(z0)),
# while at indicial 0 no power series solution exists at all.
op = PFOperator("theta0 - 1/2", no_variables=1)
ideal = PFIdeal([op])
solutions = ideal.find_power_series_solution(indicials=[1/2], order=3)
assert len(solutions) == 1
assert solutions[0].coefficients == {(0,): {(0,): 1, (1,): 0, (2,): 0, (3,): 0}}
assert solutions[0].period_string == "sqrt(z0)"
assert ideal.find_power_series_solution(indicials=[0], order=3) == []
def test_ideal_finds_power_series_solution_and_recovers_first_operator():
# Picard--Fuchs ideal for P_{22211}[8] at the MUM point:
# D1 = Theta_x^2*(Theta_x - 2*Theta_y) - 4*x*(4*Theta_x + 3)*(4*Theta_x + 2)*(4*Theta_x + 1)
# D2 = Theta_y^2 - y*(2*Theta_y - Theta_x + 1)*(2*Theta_y - Theta_x)
#
# We test moving the ideal to the intersection with the locus of Strong Coupling.
D1 = PFOperator(
"theta0^2*(theta0 - 2*theta1) - 4*z0*(4*theta0 + 3)*(4*theta0 + 2)*(4*theta0 + 1)",
no_variables=2,
)
D2 = PFOperator(
"theta1^2 - z1*(2*theta1 - theta0 + 1)*(2*theta1 - theta0)",
no_variables=2,
)
moved = PFIdeal([D1, D2]).change_coordinates("z0", "z1 - 1/4")
op1, op2 = moved.operators
solutions = moved.find_power_series_solution(indicials=[0, QQ(1) / 2], order=14)
assert len(solutions) == 1
period = solutions[0]
assert period.apply_operator(op1).coefficients == {}
assert period.apply_operator(op2).coefficients == {}
# Normalisation convention: the free parameter at the leading (0, 0) coefficient is 1.
assert period.coefficients[(0, 0)][(0, 0)] == 1
assert period.coefficients[(0, 0)][(1, 0)] == 0
recovered = period.find_annihilating_operators(z_degree=1, theta_degree=2)
assert len(recovered) == 1
assert recovered[0].operator == PFOperator(
"-2*z1*theta0^2 + 8*z1*theta0*theta1 - 8*z1*theta1^2 + 2*z1*theta0 - 4*z1*theta1 "
"+ 2*theta0*theta1 - 2*theta1^2 + theta1",
no_variables=2,
).operator
def test_single_operator_recovers_itself_from_its_holomorphic_period():
# Ensuring methods work for operator 4.2.1:
L = PFOperator(
"theta0^4 - 4*z0*(2*theta0 + 1)^2*(7*theta0^2 + 7*theta0 + 2) "
"- 128*z0^2*(2*theta0 + 1)^2*(2*theta0 + 3)^2",
no_variables=1,
)
ideal = PFIdeal([L])
solutions = ideal.find_power_series_solution(indicials=[0], order=15)
assert len(solutions) == 1
period = solutions[0]
assert period.apply_operator(L).coefficients == {}
assert period.coefficients[(0,)][(0,)] == 1
assert period.coefficients[(0,)][(1,)] == 8
assert period.coefficients[(0,)][(2,)] == 360
# L lives entirely within z_degree <= 2, theta_degree <= 4, its stated level.
recovered = period.find_annihilating_operators(z_degree=2, theta_degree=4)
assert len(recovered) == 1
# recovered[0] should be a scalar multiple of L - compare via the coefficient of the
# bare theta0^4 monomial, which is 1 in L.
ratio = recovered[0].operator.monomial_coefficient(L.theta_gens[0] ** 4) / L.operator.monomial_coefficient(
L.theta_gens[0] ** 4
)
assert ratio != 0
assert recovered[0].operator == ratio * L.operator
def test_indicials_of_quintic_at_mum_conifold_and_orbifold_points():
# At the MUM point all four indicials are 0; at the conifold point they are 0, 1, 1, 2, the doubled 1
# carrying the single logarithmic solution; at the orbifold point z0 -> 1/z0 they are 1/5, ..., 4/5.
ideal = _quintic_ideal()
assert ideal.indicials() == [((0,), 4)]
assert ideal.change_coordinates("1 - 3125*z0").indicials() == [((0,), 1), ((1,), 2), ((2,), 1)]
assert ideal.change_coordinates("1/z0").indicials() == [((QQ(k) / 5,), 1) for k in range(1, 5)]
def test_indicials_reject_hodge_numbers_inconsistent_with_the_operators():
# The quintic's operator has rank 4, so its indicials can't add up to the rank 5 of these Hodge numbers.
ideal = PFIdeal(_quintic_ideal().operators, hodge_numbers=[1, 1, 1, 1, 1])
with pytest.raises(ValueError):
ideal.indicials()
def test_frobenius_basis_of_quintic_at_mum_point():
# The solutions start with 1, log(z0), log(z0)^2 and log(z0)^3, the first being the classic holomorphic
# period. Each solution's top-log coefficient is a solution without logs, hence that period again.
ideal = _quintic_ideal()
basis = ideal.frobenius_basis(order=4)
assert [_starting_term(period) for period in basis] == [((0,), {(j,): 1}) for j in range(4)]
holomorphic = {(0,): 1, (1,): 120, (2,): 113400, (3,): 168168000, (4,): 305540235000}
assert basis[0].coefficients == {(0,): holomorphic}
assert all(period.coefficients[(j,)] == holomorphic for j, period in enumerate(basis))
assert all(period.apply_operator(op).coefficients == {} for period in basis for op in ideal.operators)
def test_frobenius_basis_of_quintic_at_conifold_point():
# The indicials 0, 1, 1, 2 differ by integers, so all four solutions come out of one solve from exponent 0.
# Picard-Lefschetz: exactly one of them is logarithmic, z0*log(z0) + ..., starting where the vanishing
# period z0 + ... does.
conifold = _quintic_ideal().change_coordinates("1 - 3125*z0")
basis = conifold.frobenius_basis(order=4)
assert [_starting_term(period) for period in basis] == [
((0,), {(0,): 1}),
((1,), {(0,): 1}),
((1,), {(1,): 1}),
((2,), {(0,): 1}),
]
assert [max(log_index[0] for log_index in period.coefficients) for period in basis] == [0, 0, 1, 0]
assert all(period.apply_operator(op).coefficients == {} for period in basis for op in conifold.operators)
def test_frobenius_basis_of_quintic_at_orbifold_point():
# Four exponent classes 1/5, ..., 4/5 mod 1, each with a single solution without logs. Order 6 reaches
# the step at which Sage's echelon form of sparse rational matrices went wrong (see operators.sage).
orbifold = _quintic_ideal().change_coordinates("1/z0")
basis = orbifold.frobenius_basis(order=6)
assert [period.indicials for period in basis] == [[QQ(k) / 5] for k in range(1, 5)]
assert all(list(period.coefficients) == [(0,)] for period in basis)
assert all(period.apply_operator(op).coefficients == {} for period in basis for op in orbifold.operators)
def test_frobenius_basis_of_two_parameter_model_at_mum_point():
# All six indicials are (0, 0), and the solutions start with 1; log(z0), log(z1); log(z0)^2,
# log(z0)*log(z1); and a triple-log term proportional to kappa_ijk*log(z_i)*log(z_j)*log(z_k) with the
# intersection numbers kappa_000 = 8, kappa_001 = 4: the log structure [1, 2, 2, 1] of the Hodge numbers.
ideal = _p22211_ideal()
assert ideal.indicials() == [((0, 0), 6)]
basis = ideal.frobenius_basis(order=3)
assert [_starting_term(period) for period in basis] == [
((0, 0), {(0, 0): 1}),
((0, 0), {(1, 0): 1}),
((0, 0), {(0, 1): 1}),
((0, 0), {(2, 0): 1}),
((0, 0), {(1, 1): 1}),
((0, 0), {(3, 0): 1, (2, 1): QQ(3) / 2}),
]
assert all(period.apply_operator(op).coefficients == {} for period in basis for op in ideal.operators)
def test_frobenius_basis_of_two_parameter_model_at_strong_coupling():
# At (z0, z1) = (0, 1/4) the generators' z-free parts theta0^2*theta1 and theta1*(2*theta1 - 2*theta0 - 1)
# vanish on the whole line theta1 = 0. Completing the indicial ideal pins the indicials down to (0, 0)
# with multiplicity 4 and (0, 1/2) with multiplicity 2. The latter class holds sqrt(z1)*(1 + ...), the
# power series solution of test_ideal_finds_power_series_solution_and_recovers_first_operator, and a
# logarithmic partner sqrt(z1)*(log(z0) + log(z1) + ...).
moved = _p22211_ideal().change_coordinates("z0", "z1 - 1/4")
assert moved.indicials() == [((0, 0), 4), ((0, QQ(1) / 2), 2)]
basis = moved.frobenius_basis(order=4)
assert [period.indicials for period in basis] == [[0, 0]] * 4 + [[0, QQ(1) / 2]] * 2
assert [_starting_term(period) for period in basis] == [
((0, 0), {(0, 0): 1}),
((0, 0), {(1, 0): 1}),
((0, 0), {(2, 0): 1}),
((0, 0), {(3, 0): 1}),
((0, 0), {(0, 0): 1}),
((0, 0), {(1, 0): 1, (0, 1): 1}),
]
power_series = moved.find_power_series_solution(indicials=[0, QQ(1) / 2], order=4)
assert basis[4].coefficients == power_series[0].coefficients
assert all(period.apply_operator(op).coefficients == {} for period in basis for op in moved.operators)
def test_frobenius_basis_warns_when_log_degree_is_capped_too_low(caplog):
# The triple-log solution at the MUM point needs log-degree 3, so with max_log_degree=2 only five of the
# six solutions survive - which is reported rather than silently returned.
ideal = _p22211_ideal()
with caplog.at_level(logging.WARNING):
basis = ideal.frobenius_basis(order=2, max_log_degree=2)
assert len(basis) == 5
assert any("rank sum(hodge_numbers) is 6" in record.getMessage() for record in caplog.records)
def test_frobenius_basis_of_two_parameter_fourfold(caplog):
# At the MUM point the nine solutions have the log structure [1, 2, 3, 2, 1], and the top ones are given by
# the intersection numbers: the quartic one by kappa_ijkl*log(z_i)*...*log(z_l), i.e. 36*log(z0)^4 +
# 48*log(z0)^3*log(z1) + 24*log(z0)^2*log(z1)^2 up to scale, a cubic one by its derivative in log(z1).
ideal = _p333111_ideal()
assert ideal.indicials() == [((0, 0), 9)]
basis = ideal.frobenius_basis(order=3)
assert [_starting_term(period) for period in basis] == [
((0, 0), {(0, 0): 1}),
((0, 0), {(1, 0): 1}),
((0, 0), {(0, 1): 1}),
((0, 0), {(2, 0): 1}),
((0, 0), {(1, 1): 1}),
((0, 0), {(0, 2): 1}),
((0, 0), {(3, 0): 1, (2, 1): 1}),
((0, 0), {(1, 2): 1}),
((0, 0), {(4, 0): 1, (3, 1): QQ(4) / 3, (2, 2): QQ(2) / 3}),
]
assert all(period.apply_operator(op).coefficients == {} for period in basis for op in ideal.operators)
# At (0, -1/27), where the strong coupling divisor 1 + 27*z1 = 0 crosses the MUM divisor, all indicials lie
# in one exponent class and there is a log(z0)^4 solution. But some indicials are (0, 1): this is no MUM
# point, and its log structure [2, 3, 2, 1, 1] must not be compared with the Hodge numbers.
moved = ideal.change_coordinates("z0", "z1 + 1/27")
with caplog.at_level(logging.WARNING):
basis = moved.frobenius_basis(order=3)
assert moved.indicials() == [((0, 0), 5), ((0, 1), 4)]
assert len(basis) == 9
assert all(period.apply_operator(op).coefficients == {} for period in basis for op in moved.operators)
assert not caplog.records
def test_frobenius_bases_at_mum_points_agree_with_the_classical_frobenius_method():
# An independent construction of every period at the MUM points, logarithmic ones included: the classical
# Frobenius method, from nothing but the Gamma-function coefficients c(k) of the fundamental period. The
# starting terms are those of the canonical bases; for P_{22211}[8] the cubic one is
# kappa_ijk*eps_i*eps_j*eps_k/8 with the intersection numbers kappa_000 = 8, kappa_001 = 4.
eps_ring = PolynomialRing(QQ, 1, ["e0"])
e = eps_ring.gen(0)
def quintic_ratio(k):
# c(n + eps)/c(eps) for c(n) = (5n)!/(n!)^5
ratio = eps_ring(1)
for i in range(1, 5 * k[0] + 1):
ratio = _truncated(ratio * (5 * e + i), 3)
for i in range(1, k[0] + 1):
ratio = _truncated(ratio * _inverse_of_linear(i, e, 3) ** 5, 3)
return ratio
classical = _classical_frobenius_periods(quintic_ratio, [e**j for j in range(4)], order=8)
basis = _quintic_ideal().frobenius_basis(order=8)
assert [_nonzero(period.coefficients) for period in basis] == [_nonzero(c) for c in classical]
eps_ring = PolynomialRing(QQ, 2, ["e0", "e1"])
e0, e1 = eps_ring.gens()
def p22211_ratio(k):
# c(n + eps0, m + eps1)/c(eps0, eps1) for c(n, m) = (4n)!/((n!)^3 (m!)^2 (n - 2m)!). For n < 2m the
# last factor is Gamma(1 + delta)/Gamma(1 + delta + n - 2m) with delta = eps0 - 2*eps1, a polynomial
# vanishing at eps = 0, which only the logarithmic periods see.
n, m = k
delta = e0 - 2 * e1
ratio = eps_ring(1)
for i in range(1, 4 * n + 1):
ratio = _truncated(ratio * (4 * e0 + i), 3)
for i in range(1, n + 1):
ratio = _truncated(ratio * _inverse_of_linear(i, e0, 3) ** 3, 3)
for i in range(1, m + 1):
ratio = _truncated(ratio * _inverse_of_linear(i, e1, 3) ** 2, 3)
for i in range(1, n - 2 * m + 1):
ratio = _truncated(ratio * _inverse_of_linear(i, delta, 3), 3)
for i in range(2 * m - n):
ratio = _truncated(ratio * (delta - i), 3)
return ratio
starting_terms = [eps_ring(1), e0, e1, e0**2, e0 * e1, e0**3 + QQ(3) / 2 * e0**2 * e1]
classical = _classical_frobenius_periods(p22211_ratio, starting_terms, order=6)
basis = _p22211_ideal().frobenius_basis(order=6)
assert [_nonzero(period.coefficients) for period in basis] == [_nonzero(c) for c in classical]
def test_quintic_bases_at_mum_and_conifold_point_continue_into_each_other():
# Both expansions converge on the lens between z0 = 0 and z0 = 1/3125, where the MUM periods must be
# constant linear combinations T of the conifold periods. Through T the conifold monodromy
# (log w -> log w + 2*pi*i) becomes a Picard-Lefschetz transvection in the MUM basis, and its product with
# the MUM monodromy is the monodromy around z0 = oo, of order 5.
CF = ComplexField(200)
two_pi_i = 2 * CF.pi() * CF.gen()
ideal = _quintic_ideal()
mum = ideal.frobenius_basis(order=100)
conifold = ideal.change_coordinates("1 - 3125*z0").frobenius_basis(order=100)
def values(basis, points, shifted=False):
# One row per point z0, one column per period, continued once around w = 0 if shifted.
rows = []
for z0 in points:
w = z0 if basis is mum else 1 - 3125 * z0
log_w = CF(w).log() + (two_pi_i if shifted else 0)
rows.append([
sum(
CF(coeff) * log_w ** log_index[0] * ((z_index[0] + period.indicials[0]) * log_w).exp()
for log_index, z_dict in period.coefficients.items()
for z_index, coeff in z_dict.items()
if coeff != 0
)
for period in basis
])
return matrix(CF, rows)
fit = [CF(x, y) / 3125 for x, y in [(0.4, -0.15), (0.5, 0.1), (0.6, -0.1), (0.45, 0.15)]]
test = [CF(x, y) / 3125 for x, y in [(0.55, 0.05), (0.42, -0.05), (0.6, 0.12)]]
T = values(conifold, fit).solve_right(values(mum, fit)).transpose()
assert (values(mum, test) - values(conifold, test) * T.transpose()).norm() < 1e-8 * values(mum, test).norm()
M_0 = values(mum, fit).solve_right(values(mum, fit, shifted=True)).transpose()
M_c = T * values(conifold, fit).solve_right(values(conifold, fit, shifted=True)).transpose() * T.inverse()
identity = identity_matrix(CF, 4)
singular_values = (M_c - identity).change_ring(CDF).SVD()[1].diagonal()
assert singular_values[1] < 1e-8 * singular_values[0]
assert ((M_0 * M_c) ** 5 - identity).norm() < 1e-8