diff --git a/lmfdb/ecnf/WebEllipticCurve.py b/lmfdb/ecnf/WebEllipticCurve.py
index 9735723756..df27f7c12a 100644
--- a/lmfdb/ecnf/WebEllipticCurve.py
+++ b/lmfdb/ecnf/WebEllipticCurve.py
@@ -420,6 +420,14 @@ def make_E(self):
self.fact_mindisc = latex_factorization(badprimes, mindisc_ords)
self.fact_mindisc_norm = latex_factorization(badnorms, mindisc_ords, sign=signDnorm)
+ # Szpiro ratio log(Norm(mindisc))/log(Norm(cond)). The
+ # attribute is set from the database row when the
+ # szpiro_ratio column exists; it is None for curves with
+ # everywhere good reduction, for which the ratio is not
+ # defined. The default here keeps this class working until
+ # the column has been added to ec_nfcurves.
+ self.szpiro_ratio = getattr(self, "szpiro_ratio", None)
+
j = self.field.parse_NFelt(self.jinv)
self.j = web_latex(j)
self.fact_j = None
diff --git a/lmfdb/ecnf/main.py b/lmfdb/ecnf/main.py
index 2c52536b72..c06e4c4ffe 100644
--- a/lmfdb/ecnf/main.py
+++ b/lmfdb/ecnf/main.py
@@ -375,6 +375,14 @@ def parse_cm_list(inp, query, qfield):
Ra = PolynomialRing(QQ,'a')
+# The szpiro_ratio column is computed and uploaded by scripts/ecnf/generate_szpiro_ratio.py.
+# All uses are guarded by this flag, which is a startup-time compatibility guard: it is
+# evaluated once when this module is imported, so old-schema deployments keep working, but
+# the web workers must be restarted after the column has been added and populated so that
+# ecnf_columns, the search array and the sort choices below (also built at import time) are
+# rebuilt. Refreshing the schema of a running worker is not enough.
+HAVE_SZPIRO_RATIO = "szpiro_ratio" in db.ec_nfcurves.search_cols
+
ecnf_columns = SearchColumns([
MultiProcessedCol("label", "ec.curve_label", "Label", ["short_label", "field_label", "conductor_label", "iso_label", "number"],
lambda label, field, conductor, iso, number: '%s' % (
@@ -395,6 +403,7 @@ def parse_cm_list(inp, query, qfield):
ProcessedCol("conductor_norm", "ec.conductor", "Conductor norm", lambda v: web_latex_factored_integer(ZZ(v)), align="center"),
ProcessedCol("normdisc", "ec.discriminant", "Discriminant norm", lambda v: web_latex_factored_integer(ZZ(v)), align="center", default=False),
FloatCol("root_analytic_conductor", "lfunction.root_analytic_conductor", "Root analytic conductor", prec=5, default=False),
+ *([FloatCol("szpiro_ratio", "ec.szpiro_ratio", "Szpiro ratio", prec=5, default=False)] if HAVE_SZPIRO_RATIO else []),
ProcessedCol("bad_primes", "ec.bad_reduction", "Bad primes",
lambda primes: ", ".join(''.join(str(p.replace('w', 'a')).split('*')) for p in primes) if primes else r"\textsf{none}",
default=lambda info: info.get("bad_primes"), mathmode=True, align="center"),
@@ -512,6 +521,8 @@ def elliptic_curve_search(info, query):
parse_ints(info,query,'class_deg','class_deg')
parse_ints(info,query,'sha','analytic order of Ш')
parse_floats(info,query,'regulator',name='regulator',qfield='reg')
+ if HAVE_SZPIRO_RATIO:
+ parse_floats(info,query,'szpiro_ratio','Szpiro ratio')
parse_nf_jinv(info,query,'jinv','j-invariant',field_label=query.get('field_label'))
if info.get('one') == "yes":
@@ -795,7 +806,8 @@ class ECNFSearchArray(SearchArray):
("reg", "regulator", ["reg", 'degree', 'signature', 'abs_disc', 'field_label', 'conductor_norm', 'conductor_label', 'iso_nlabel', 'number']),
("sha", "analytic Ш", ["sha", 'degree', 'signature', 'abs_disc', 'field_label', 'conductor_norm', 'conductor_label', 'iso_nlabel', 'number']),
("class_size", "isogeny class size", ["class_size", 'degree', 'signature', 'abs_disc', 'field_label', 'conductor_norm', 'conductor_label', 'iso_nlabel', 'number']),
- ("class_deg", "isogeny class degree", ["class_deg", 'degree', 'signature', 'abs_disc', 'field_label', 'conductor_norm', 'conductor_label', 'iso_nlabel', 'number'])]
+ ("class_deg", "isogeny class degree", ["class_deg", 'degree', 'signature', 'abs_disc', 'field_label', 'conductor_norm', 'conductor_label', 'iso_nlabel', 'number'])] \
+ + ([("szpiro_ratio", "Szpiro ratio", ["szpiro_ratio", 'degree', 'signature', 'abs_disc', 'field_label', 'conductor_norm', 'conductor_label', 'iso_nlabel', 'number'])] if HAVE_SZPIRO_RATIO else [])
jump_example = "2.2.5.1-31.1-a1"
jump_egspan = "e.g. 2.2.5.1-31.1-a1 or 2.2.5.1-31.1-a"
jump_knowl = "ec.search_input"
@@ -947,6 +959,12 @@ def __init__(self):
label="Base change of",
knowl="ec.base_change",
example="11a.1")
+ szpiro_ratio = TextBox(
+ name="szpiro_ratio",
+ label="Szpiro ratio",
+ knowl="ec.szpiro_ratio",
+ example="4.5",
+ example_span="4.5 or 4-5")
count = CountBox()
self.browse_array = [
@@ -960,6 +978,7 @@ def __init__(self):
[class_size, class_deg],
[galois_image, nonmax_primes],
[base_change_label, reduction],
+ *([[szpiro_ratio]] if HAVE_SZPIRO_RATIO else []),
[jinv],
[count]
]
@@ -969,5 +988,6 @@ def __init__(self):
[deg_sig, bad_primes, Qcurves, torsion_structure, include_cm],
[sha, isodeg, class_size, reduction, galois_image],
[base_change_label, regulator, one, class_deg, nonmax_primes],
+ *([[szpiro_ratio]] if HAVE_SZPIRO_RATIO else []),
[jinv],
]
diff --git a/lmfdb/ecnf/templates/ecnf-curve.html b/lmfdb/ecnf/templates/ecnf-curve.html
index 03297f5e55..f32ec653dd 100644
--- a/lmfdb/ecnf/templates/ecnf-curve.html
+++ b/lmfdb/ecnf/templates/ecnf-curve.html
@@ -249,6 +249,15 @@
{{ KNOWL('ec.invariants', title='Invariants')}}
+{% if ec.szpiro_ratio %}
+
+ | {{ KNOWL('ec.szpiro_ratio', title='Szpiro ratio') }}: |
+ $\sigma$ |
+ ≈ |
+ ${{ ec.szpiro_ratio }}$ |
+
+{% endif %}
+
diff --git a/lmfdb/ecnf/test_ecnf.py b/lmfdb/ecnf/test_ecnf.py
index 540e4ca3a8..564fef09c4 100644
--- a/lmfdb/ecnf/test_ecnf.py
+++ b/lmfdb/ecnf/test_ecnf.py
@@ -1,5 +1,27 @@
+import os
+import re
+
from lmfdb.tests import LmfdbTest
+
+def szpiro_generator():
+ """The scripts/ecnf/generate_szpiro_ratio.py module, loaded by path.
+
+ ``scripts`` is not part of the lmfdb package, so it cannot simply be
+ imported; ``None`` is returned when it is absent (e.g. when only the
+ package has been installed).
+ """
+ import importlib.util
+ path = os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, os.pardir,
+ "scripts", "ecnf", "generate_szpiro_ratio.py")
+ if not os.path.exists(path):
+ return None
+ spec = importlib.util.spec_from_file_location("generate_szpiro_ratio", path)
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
+
+
class EllCurveTest(LmfdbTest):
# All tests should pass
@@ -121,6 +143,98 @@ def test_isodeg(self):
L = self.tc.get('/EllipticCurve/?start=0&torsion=1&isodeg=2')
assert 'No matches' in L.get_data(as_text=True)
+ def test_szpiro_ratio(self):
+ r"""
+ Test that the Szpiro ratio is displayed, searchable and sortable
+ once ec_nfcurves has the szpiro_ratio column, and that the pages
+ still work (without offering it) while the column is missing
+ """
+ from lmfdb.ecnf.main import HAVE_SZPIRO_RATIO
+ # 2.2.5.1-31.1-a1 has Szpiro ratio exactly 1 (Norm(D_min) = Norm(N) = 31);
+ # 3.3.1369.1-1.1-a1 has everywhere good reduction, so no ratio at all.
+ curve_url = '/EllipticCurve/2.2.5.1/31.1/a/1'
+ egr_url = '/EllipticCurve/3.3.1369.1/1.1/a/1'
+ t = self.tc.get(curve_url).get_data(as_text=True)
+ assert 'Conductor norm' in t
+
+ if not HAVE_SZPIRO_RATIO:
+ # Compatibility branch: the pages load and offer no ratio anywhere.
+ # A szpiro_ratio constraint is deliberately ignored rather than
+ # raising, so the search below only shows that the page still works;
+ # it says nothing about filtering, which needs the column.
+ assert 'Szpiro ratio' not in t
+ assert 'Szpiro ratio' not in self.tc.get('/EllipticCurve/').get_data(as_text=True)
+ assert self.tc.get('/EllipticCurve/?field=2.2.5.1&szpiro_ratio=0.5-1.5').status_code == 200
+ return
+
+ # Displayed on the curve page, with the right value. Match the label
+ # text rather than the knowl markup around it: KNOWL() renders a plain
+ # label until ec.szpiro_ratio has been created, and an anchor after.
+ row = re.search(r'Szpiro ratio.*?', t, re.DOTALL)
+ assert row is not None, 'no Szpiro ratio row on %s' % curve_url
+ assert re.search(r'\$\s*1\.0\s*\$', row.group(0)), row.group(0)
+ # Omitted for a curve with everywhere good reduction, where it is undefined.
+ assert 'Szpiro ratio' not in self.tc.get(egr_url).get_data(as_text=True)
+
+ # A range containing 1.0 finds the curve and a disjoint range does not:
+ # the pair is what shows that the constraint reaches the query at all.
+ t = self.tc.get('/EllipticCurve/?field=2.2.5.1&szpiro_ratio=0.5-1.5').get_data(as_text=True)
+ assert curve_url in t
+ t = self.tc.get('/EllipticCurve/?field=2.2.5.1&szpiro_ratio=1.1-1.5').get_data(as_text=True)
+ assert curve_url not in t
+
+ # Sorting by the ratio works, and shows the column even though it is
+ # off by default: without the sort its results-table header carries
+ # display:none (it is always in the html, for the column selector).
+ L = self.tc.get('/EllipticCurve/?field=2.2.5.1&sort_order=szpiro_ratio')
+ assert L.status_code == 200
+ th = re.search(r'>> from lmfdb import db
+ >>> db.ec_nfcurves.add_column("szpiro_ratio", "double precision",
+ ... description="Szpiro ratio log(Norm(D_min))/log(Norm(N)), NULL for curves with everywhere good reduction")
+
+4. Upload the data and check that every row was set::
+
+ >>> db.ec_nfcurves.update_from_file("ec_nfcurves_szpiro.txt")
+ >>> db.ec_nfcurves.count({"szpiro_ratio": {"$exists": True}})
+ >>> db.ec_nfcurves.count() - db.ec_nfcurves.count({"conductor_norm": 1}) # same number
+ >>> db.ec_nfcurves.count({"conductor_norm": 1, "szpiro_ratio": {"$exists": True}}) # 0
+
+ (``update_from_file`` merges into a new table and swaps it in, so it
+ can be undone with ``reload_revert``.)
+
+5. Create the index backing the new range search and sort. Do this
+ *after* the upload: the swap in step 4 rebuilds every index of the
+ table from scratch, so an index created first would be built twice::
+
+ >>> szpiro_sort = ["szpiro_ratio", "degree", "signature", "abs_disc",
+ ... "field_label", "conductor_norm", "conductor_label",
+ ... "iso_nlabel", "number"]
+ >>> if "ec_nfcurves_szpiro_ratio_sort" not in db.ec_nfcurves.list_indexes():
+ ... db.ec_nfcurves.create_index(szpiro_sort, name="ec_nfcurves_szpiro_ratio_sort")
+
+ The column list is exactly the sort tuple registered for "Szpiro
+ ratio" in ``ECNFSearchArray.sorts``. Use ``create_index`` rather
+ than raw SQL: it records the definition in ``meta_indexes``, which is
+ what lets later reloads rebuild the index. Then check that it is
+ there and that the planner uses it::
+
+ >>> db.ec_nfcurves.list_indexes(verbose=True)
+ >>> db.ec_nfcurves.analyze({}, projection=["label", "szpiro_ratio"],
+ ... limit=50, sort=szpiro_sort, explain_only=True)
+ >>> db.ec_nfcurves.analyze({"szpiro_ratio": {"$gte": 6, "$lte": 7}},
+ ... projection=["label", "szpiro_ratio"],
+ ... limit=50, sort=szpiro_sort, explain_only=True)
+
+ Both plans should use ec_nfcurves_szpiro_ratio_sort instead of a
+ sequential scan followed by a sort. Do not restart the website until
+ the index has finished building.
+
+6. Restart/reload **every** web worker, so that HAVE_SZPIRO_RATIO, the
+ result columns, the search boxes and the sort choices are rebuilt.
+
+7. Smoke test display, search and sort: /EllipticCurve/2.2.5.1/31.1/a/1
+ shows a Szpiro ratio of 1.0; /EllipticCurve/?field=2.2.5.1&szpiro_ratio=0.5-1.5
+ contains it and ...&szpiro_ratio=1.1-1.5 does not; and
+ /EllipticCurve/?sort_order=szpiro_ratio returns promptly.
+"""
+import argparse
+import os
+import random
+import sys
+import tempfile
+from math import log
+
+sys.path.append(os.path.join(os.path.dirname(__file__), "..", ".."))
+from lmfdb import db
+
+
+def min_disc_norm(rec):
+ """Norm of the minimal discriminant ideal, computed from local_data."""
+ return prod_pow([(ld["normp"], ld["ord_disc"]) for ld in rec["local_data"]])
+
+
+def min_disc_norm_from_normdisc(rec):
+ """Norm of the minimal discriminant ideal, computed from normdisc.
+
+ The stored model is minimal outside the primes in non_min_p, at each
+ of which the valuation of its discriminant is 12 more than that of
+ the minimal discriminant.
+ """
+ normdisc = abs(int(rec["normdisc"]))
+ D = normdisc
+ for p in rec["non_min_p"]:
+ normp = None
+ for ld in rec["local_data"]:
+ if ld["p"] == p:
+ normp = ld["normp"]
+ break
+ if normp is None:
+ raise ValueError(
+ "%s: no local_data entry for the non-minimal prime %s "
+ "(local_data covers %s)"
+ % (rec["label"], p, ", ".join(ld["p"] for ld in rec["local_data"]) or "no primes"))
+ D, r = divmod(D, normp**12)
+ if r:
+ raise ValueError(
+ "%s: |normdisc| = %s is not divisible by normp^12 = %s^12 at the "
+ "non-minimal prime %s" % (rec["label"], normdisc, normp, p))
+ return D
+
+
+def szpiro_ratio(Dnorm, Nnorm, label=None):
+ """log(Dnorm)/log(Nnorm), or None if not defined (trivial conductor).
+
+ ``Dnorm`` is the norm of the minimal discriminant ideal and ``Nnorm``
+ the norm of the conductor; both must be positive integers. ``label``
+ is only used to make the error messages identify the offending curve.
+ """
+ where = "" if label is None else "%s: " % label
+ if Dnorm <= 0:
+ raise ValueError("%sminimal discriminant norm is %s, expected a positive integer"
+ % (where, Dnorm))
+ if Nnorm <= 0:
+ raise ValueError("%sconductor norm is %s, expected a positive integer"
+ % (where, Nnorm))
+ if Nnorm == 1:
+ # everywhere good reduction: D_min = (1) too, sigma undefined
+ if Dnorm != 1:
+ raise ValueError("%sconductor norm is 1 but the minimal discriminant has norm %s"
+ % (where, Dnorm))
+ return None
+ return log(Dnorm) / log(Nnorm)
+
+
+def prod_pow(pairs):
+ D = 1
+ for p, e in pairs:
+ D *= p**e
+ return D
+
+
+def generate(outfile, limit=None, sample_check=1000, verify_count=0, seed=None):
+ """Write the szpiro_ratio data file for ec_nfcurves, or fail without writing it.
+
+ Rows go to a temporary file in the same directory as ``outfile``,
+ which is replaced only once everything below has succeeded; on any
+ failure the temporary file is removed and ``outfile`` is untouched.
+ """
+ rand = random.Random(seed)
+
+ total = db.ec_nfcurves.count()
+ minimal_count = db.ec_nfcurves.count({"non_min_p": []})
+ nonminimal_count = db.ec_nfcurves.count({"non_min_p": {"$ne": []}})
+ if minimal_count + nonminimal_count != total:
+ raise RuntimeError(
+ "ec_nfcurves has %s rows, but %s have non_min_p = [] and %s have "
+ "non_min_p != []; the %s remaining rows (most likely NULL non_min_p) "
+ "would be silently omitted"
+ % (total, minimal_count, nonminimal_count,
+ total - minimal_count - nonminimal_count))
+
+ processed_minimal = minimal_count if limit is None else min(limit, minimal_count)
+ processed_nonminimal = nonminimal_count if limit is None else min(limit, nonminimal_count)
+ expected = processed_minimal + processed_nonminimal
+
+ # Cross-check exactly this many minimal rows, at positions drawn up front,
+ # rather than with a probability derived from the size of the whole table.
+ target_checks = min(sample_check, processed_minimal)
+ check_positions = set(rand.sample(range(processed_minimal), target_checks))
+
+ directory = os.path.dirname(os.path.abspath(outfile))
+ fd, tmpfile = tempfile.mkstemp(dir=directory, prefix=os.path.basename(outfile) + ".")
+ try:
+ nulls = written = checked = 0
+ with os.fdopen(fd, "w") as F:
+ F.write("label|szpiro_ratio\ntext|double precision\n\n")
+
+ # Curves whose stored model is globally minimal: here
+ # Norm(D_min) = |normdisc| and we do not need local_data.
+ # The sampled positions are cross-checked against local_data.
+ for i, rec in enumerate(db.ec_nfcurves.search(
+ {"non_min_p": []},
+ ["label", "conductor_norm", "normdisc"],
+ sort=[], limit=limit)):
+ Dnorm = abs(int(rec["normdisc"]))
+ sigma = szpiro_ratio(Dnorm, rec["conductor_norm"], rec["label"])
+ if i in check_positions:
+ full = db.ec_nfcurves.lookup(rec["label"], ["label", "local_data", "non_min_p"])
+ from_local_data = min_disc_norm(full)
+ if from_local_data != Dnorm:
+ raise ValueError(
+ "%s: |normdisc| = %s but local_data gives Norm(D_min) = %s"
+ % (rec["label"], Dnorm, from_local_data))
+ checked += 1
+ F.write("%s|%s\n" % (rec["label"], r"\N" if sigma is None else repr(sigma)))
+ written += 1
+ nulls += sigma is None
+ if written % 100000 == 0:
+ print("%s/%s done" % (written, expected))
+
+ # Curves stored with a non-minimal model: compute from
+ # local_data and cross-check against normdisc.
+ for rec in db.ec_nfcurves.search(
+ {"non_min_p": {"$ne": []}},
+ ["label", "conductor_norm", "normdisc", "non_min_p", "local_data"],
+ sort=[], limit=limit):
+ Dnorm = min_disc_norm(rec)
+ from_normdisc = min_disc_norm_from_normdisc(rec)
+ if Dnorm != from_normdisc:
+ raise ValueError(
+ "%s: local_data gives Norm(D_min) = %s but normdisc gives %s"
+ % (rec["label"], Dnorm, from_normdisc))
+ sigma = szpiro_ratio(Dnorm, rec["conductor_norm"], rec["label"])
+ F.write("%s|%s\n" % (rec["label"], r"\N" if sigma is None else repr(sigma)))
+ written += 1
+ nulls += sigma is None
+
+ if checked != target_checks:
+ raise RuntimeError("asked for %s minimal-model cross-checks but made %s"
+ % (target_checks, checked))
+ if written != expected:
+ raise RuntimeError(
+ "expected to write %s rows (%s minimal + %s non-minimal) but wrote %s"
+ % (expected, processed_minimal, processed_nonminimal, written))
+ print("Generated %s rows (%s NULL, i.e. everywhere good reduction)" % (written, nulls))
+ print("Cross-checked %s minimal-model rows against local_data" % checked)
+
+ # Verify before installing the file, so that a failure leaves nothing behind.
+ if verify_count:
+ verify(tmpfile, verify_count, rand=rand)
+
+ # mkstemp creates the file 0600; the data file is meant to be readable
+ # by whoever uploads it, as it would be if we had just opened outfile.
+ os.chmod(tmpfile, 0o644)
+ os.replace(tmpfile, outfile)
+ except BaseException:
+ if os.path.exists(tmpfile):
+ os.unlink(tmpfile)
+ raise
+ print("Wrote %s" % outfile)
+
+
+def verify(datafile, nchecks=20, rand=None):
+ """Recompute szpiro_ratio for random rows of the output file from scratch.
+
+ This is an independent check: the curve is rebuilt in Sage from its
+ a-invariants and the norms of its conductor and minimal discriminant
+ ideal are recomputed, without using conductor_norm, normdisc or
+ local_data.
+ """
+ from sage.all import EllipticCurve
+ from lmfdb.ecnf.WebEllipticCurve import FIELD, parse_ainvs
+
+ if rand is None:
+ rand = random.Random()
+ with open(datafile) as F:
+ lines = F.read().splitlines()[3:]
+ if nchecks > len(lines):
+ raise ValueError(
+ "asked to verify %s rows from scratch, but the generated file has "
+ "only %s data rows; use --verify %s or fewer (or raise --limit)"
+ % (nchecks, len(lines), len(lines)))
+ for line in rand.sample(lines, nchecks):
+ label, _, sigma = line.partition("|")
+ rec = db.ec_nfcurves.lookup(label, ["field_label", "ainvs", "base_change"])
+ K = FIELD(rec["field_label"]).K()
+ E = EllipticCurve(parse_ainvs(K, rec["ainvs"]))
+ Nnorm = E.conductor().norm()
+ Dnorm = E.minimal_discriminant_ideal().norm()
+ if Nnorm == 1:
+ if sigma != r"\N":
+ raise ValueError("%s: has everywhere good reduction, so the file should "
+ "give \\N, but it gives %s" % (label, sigma))
+ print("%s: everywhere good reduction, NULL ok" % label)
+ else:
+ if sigma == r"\N":
+ raise ValueError("%s: file gives \\N but the conductor has norm %s"
+ % (label, Nnorm))
+ recomputed = log(Dnorm) / log(Nnorm)
+ if abs(recomputed - float(sigma)) >= 1e-12:
+ raise ValueError("%s: file has %s but Sage gives %s"
+ % (label, sigma, recomputed))
+ print("%s: %s ok (Norm(D_min)=%s, Norm(N)=%s, base change of %s)"
+ % (label, sigma, Dnorm, Nnorm, rec["base_change"] or "nothing"))
+
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
+ parser.add_argument("outfile", help="output file (update_from_file format)")
+ parser.add_argument("--limit", type=int, default=None,
+ help="only process this many rows from each query (sample run)")
+ parser.add_argument("--sample-check", type=int, default=1000,
+ help="number of minimal-model rows to cross-check against local_data")
+ parser.add_argument("--verify", type=int, default=0, metavar="N",
+ help="before installing the file, recompute N random rows from scratch with Sage")
+ parser.add_argument("--seed", type=int, default=None,
+ help="seed for the rows sampled by --sample-check and --verify")
+ args = parser.parse_args()
+ for opt in ("limit", "sample_check", "verify"):
+ value = getattr(args, opt)
+ if value is not None and value < 0:
+ parser.error("--%s must be nonnegative (got %s)" % (opt.replace("_", "-"), value))
+ generate(args.outfile, limit=args.limit, sample_check=args.sample_check,
+ verify_count=args.verify, seed=args.seed)
|